jeo-code 0.6.14 → 0.6.16

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,22 @@ 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.16] - 2026-06-17
10
+ _OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional._
11
+
12
+ ### Added
13
+ - **Concept cross-link graph for the memory bundle (OKF Sprint 04).** A new zero-dependency `src/agent/memory-graph.ts` treats the OKF bundle as a first-class link graph — nodes are concept IDs, edges are the markdown links a concept's body points at another concept, and broken links are tolerated (captured for lint, never thrown). It powers `buildConceptGraph` / `expandByGraph` / `resolveLinkTarget` / `lintConceptGraph` / `graphifyAvailable`. Memory injection now applies **1-hop graph expansion**: a concept the task query directly hits lifts its link-neighbours ahead of unrelated noise (still within `MEMORY_INJECT_MAX_CHARS`). New `lintMemoryBundle(cwd)` reports orphan concepts, broken links, and duplicate-title merge candidates. The optional `graphify` tool is a best-effort enrichment layer only — every feature runs fully on the built-in graph when it is absent (graceful degradation), and `graphify update` is never run against the markdown bundle.
14
+
15
+ ## [0.6.15] - 2026-06-17
16
+ _Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt._
17
+
18
+ ### Added
19
+ - **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.
20
+
21
+ ### Changed
22
+ - **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.
23
+
24
+
9
25
  ## [0.6.14] - 2026-06-16
10
26
  _Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn._
11
27
 
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.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
162
+ - **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
161
163
  - **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
162
164
  - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
163
165
  - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
164
- - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
165
- - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
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.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
162
+ - **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
161
163
  - **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
162
164
  - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
163
165
  - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
164
- - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
165
- - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
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.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
162
+ - **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
161
163
  - **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
162
164
  - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
163
165
  - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
164
- - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
165
- - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
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.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
162
+ - **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
161
163
  - **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
162
164
  - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
163
165
  - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
164
- - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
165
- - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.14",
3
+ "version": "0.6.16",
4
4
 
5
5
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
6
6
  "type": "module",
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Concept cross-link graph for the OKF memory bundle — OKF Sprint 04 (Graph Layer).
3
+ *
4
+ * A first-class, zero-dependency link graph over the concept bundle: nodes are
5
+ * concept IDs (bundle-relative path minus `.md`), edges are the markdown links a
6
+ * concept's body points at another concept. Broken links (targets with no node)
7
+ * are TOLERATED — OKF's lenient model treats them as "knowledge not yet written"
8
+ * and the lint pass reports them rather than failing.
9
+ *
10
+ * Used to (1) strengthen Sprint 03 search by 1-hop graph expansion (a concept
11
+ * the query directly hits pulls in its neighbours as injection candidates) and
12
+ * (2) lint the bundle (orphans / broken links / duplicate-title candidates).
13
+ *
14
+ * graphify (the external graph tool) is an OPTIONAL second layer: when present
15
+ * it can enrich, but every feature here runs fully on the built-in graph so the
16
+ * bundle works with graphify absent (graceful degradation). Note: `graphify
17
+ * update` is for CODE repos only — never run it against this markdown bundle.
18
+ *
19
+ * Design contract: docs/okf_mem/sprint-04-graph-layer/index.md
20
+ */
21
+ import * as posix from "node:path/posix";
22
+ import { conceptId } from "./memory-okf";
23
+
24
+ /** Minimal shape the graph needs from a concept (a `Concept` satisfies it). */
25
+ export interface GraphConcept {
26
+ /** Bundle-relative path, e.g. `commands/bun-test.md`. */
27
+ relPath: string;
28
+ /** Markdown body; scanned for `](target)` links. */
29
+ body: string;
30
+ /** Optional concept title (for duplicate-title lint). */
31
+ title?: string;
32
+ }
33
+
34
+ /** A directed cross-link graph over concept IDs. */
35
+ export interface ConceptGraph {
36
+ /** All concept IDs present in the bundle. */
37
+ nodes: Set<string>;
38
+ /** from-ID → set of to-IDs that resolve to a real node. */
39
+ edges: Map<string, Set<string>>;
40
+ /** from-ID → set of link targets that have NO node (tolerated broken links). */
41
+ broken: Map<string, Set<string>>;
42
+ }
43
+
44
+ /** A markdown inline link `](target)` — captures the target, not the label. */
45
+ const LINK_RE = /\]\(([^)\s]+)\)/g;
46
+
47
+ function addEdge(map: Map<string, Set<string>>, from: string, to: string): void {
48
+ const set = map.get(from) ?? new Set<string>();
49
+ set.add(to);
50
+ map.set(from, set);
51
+ }
52
+
53
+ /**
54
+ * Resolve a markdown link target (as written in `from`'s body) to a concept ID,
55
+ * or `null` when it is not an in-bundle concept reference. Handles:
56
+ * - external/protocol links (`http://…`, `mailto:`) → null
57
+ * - pure anchors (`#section`) → null
58
+ * - bundle-absolute (`/commands/x.md`) → `commands/x`
59
+ * - relative (`../facts/x.md`) → resolved against `from`'s directory
60
+ * Anchors and query strings are stripped before ID normalization.
61
+ */
62
+ export function resolveLinkTarget(fromId: string, rawTarget: string): string | null {
63
+ let t = rawTarget.trim();
64
+ if (!t || t.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(t)) return null; // external/protocol/anchor
65
+ t = t.split("#")[0]!.split("?")[0]!.trim();
66
+ if (!t) return null;
67
+ let full: string;
68
+ if (t.startsWith("/")) {
69
+ full = t.replace(/^\/+/, "");
70
+ } else {
71
+ const slash = fromId.lastIndexOf("/");
72
+ const fromDir = slash === -1 ? "" : fromId.slice(0, slash);
73
+ full = fromDir ? posix.normalize(`${fromDir}/${t}`) : posix.normalize(t);
74
+ }
75
+ if (!full || full.startsWith("..")) return null; // escaped the bundle — not a concept ref
76
+ const id = conceptId(full);
77
+ return id || null;
78
+ }
79
+
80
+ /** Build the cross-link graph from a set of concepts. Broken links are kept
81
+ * (in `broken`) rather than dropped, so lint can surface them. */
82
+ export function buildConceptGraph(concepts: GraphConcept[]): ConceptGraph {
83
+ const nodes = new Set(concepts.map(c => conceptId(c.relPath)));
84
+ const edges = new Map<string, Set<string>>();
85
+ const broken = new Map<string, Set<string>>();
86
+ for (const c of concepts) {
87
+ const from = conceptId(c.relPath);
88
+ LINK_RE.lastIndex = 0;
89
+ let m: RegExpExecArray | null;
90
+ while ((m = LINK_RE.exec(c.body)) !== null) {
91
+ const to = resolveLinkTarget(from, m[1]!);
92
+ if (!to || to === from) continue;
93
+ if (nodes.has(to)) addEdge(edges, from, to);
94
+ else addEdge(broken, from, to);
95
+ }
96
+ }
97
+ return { nodes, edges, broken };
98
+ }
99
+
100
+ /** Undirected adjacency (links are navigable both ways for discovery). */
101
+ function undirectedAdjacency(graph: ConceptGraph): Map<string, Set<string>> {
102
+ const adj = new Map<string, Set<string>>();
103
+ for (const [from, tos] of graph.edges) {
104
+ for (const to of tos) {
105
+ addEdge(adj, from, to);
106
+ addEdge(adj, to, from);
107
+ }
108
+ }
109
+ return adj;
110
+ }
111
+
112
+ /**
113
+ * Expand a set of seed concept IDs along the (undirected) link graph by up to
114
+ * `hops` steps, returning seeds + reachable neighbours. Seeds not present as
115
+ * nodes are dropped. With `hops` 0 this is just the valid seeds.
116
+ */
117
+ export function expandByGraph(seedIds: Iterable<string>, graph: ConceptGraph, hops = 1): Set<string> {
118
+ const adj = undirectedAdjacency(graph);
119
+ const result = new Set<string>();
120
+ for (const id of seedIds) if (graph.nodes.has(id)) result.add(id);
121
+ let frontier = [...result];
122
+ for (let h = 0; h < hops && frontier.length > 0; h++) {
123
+ const next: string[] = [];
124
+ for (const id of frontier) {
125
+ for (const nb of adj.get(id) ?? []) {
126
+ if (!result.has(nb)) {
127
+ result.add(nb);
128
+ next.push(nb);
129
+ }
130
+ }
131
+ }
132
+ frontier = next;
133
+ }
134
+ return result;
135
+ }
136
+
137
+ /** A lenient lint report over the concept graph (warnings, never hard failures). */
138
+ export interface GraphLintReport {
139
+ /** Concept IDs with no incoming or outgoing edges. */
140
+ orphans: string[];
141
+ /** Links whose target resolves to no concept node. */
142
+ brokenLinks: { from: string; to: string }[];
143
+ /** Concepts that share a (case-insensitive) title — merge/contradiction candidates. */
144
+ duplicates: { title: string; ids: string[] }[];
145
+ }
146
+
147
+ /** Lint the bundle's graph: orphan concepts, broken links, duplicate titles.
148
+ * Purely advisory — mirrors llm-wiki's lint pass (broken/orphan/contradiction). */
149
+ export function lintConceptGraph(concepts: GraphConcept[], graph: ConceptGraph): GraphLintReport {
150
+ const linked = new Set<string>();
151
+ for (const [from, tos] of graph.edges) {
152
+ if (tos.size > 0) linked.add(from);
153
+ for (const to of tos) linked.add(to);
154
+ }
155
+ const orphans = [...graph.nodes].filter(id => !linked.has(id)).sort();
156
+
157
+ const brokenLinks: { from: string; to: string }[] = [];
158
+ for (const [from, tos] of graph.broken) {
159
+ for (const to of tos) brokenLinks.push({ from, to });
160
+ }
161
+ brokenLinks.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
162
+
163
+ const byTitle = new Map<string, string[]>();
164
+ for (const c of concepts) {
165
+ const title = (c.title ?? "").trim();
166
+ if (!title) continue;
167
+ const key = title.toLowerCase();
168
+ const ids = byTitle.get(key) ?? [];
169
+ ids.push(conceptId(c.relPath));
170
+ byTitle.set(key, ids);
171
+ }
172
+ const duplicates: { title: string; ids: string[] }[] = [];
173
+ for (const ids of byTitle.values()) {
174
+ if (ids.length > 1) duplicates.push({ title: ids.length ? (concepts.find(c => conceptId(c.relPath) === ids[0])?.title ?? "").trim() : "", ids: ids.sort() });
175
+ }
176
+ duplicates.sort((a, b) => a.title.localeCompare(b.title));
177
+
178
+ return { orphans, brokenLinks, duplicates };
179
+ }
180
+
181
+ /**
182
+ * Whether the optional graphify tool is available on PATH. The built-in graph
183
+ * is always the primary layer; graphify is a best-effort enrichment, so callers
184
+ * degrade gracefully when this is false. `detect` is injectable for testing.
185
+ */
186
+ export function graphifyAvailable(detect: () => boolean = defaultGraphifyDetect): boolean {
187
+ try {
188
+ return detect();
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ function defaultGraphifyDetect(): boolean {
195
+ // Bun.which resolves a binary on PATH without spawning it.
196
+ const which = (globalThis as { Bun?: { which?: (cmd: string) => string | null } }).Bun?.which;
197
+ return typeof which === "function" ? which("graphify") != null : false;
198
+ }
@@ -3,18 +3,21 @@
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/MEMORY.md` with ONE model call,
7
- * merging into the existing doc. The next session injects the doc back into
8
- * the system prompt under a hard char cap local-first (nullclaw/zeroclaw),
9
- * no remote backend, disable with JEO_NO_MEMORY=1.
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";
13
15
  import * as path from "node:path";
14
16
  import { callLlm, type Message } from "./loop";
15
17
  import { jeoEnv } from "../util/env";
16
- import { parseConcept, serializeConcept, slugify, isReservedFile } from "./memory-okf";
18
+ import { parseConcept, serializeConcept, slugify, isReservedFile, conceptId } from "./memory-okf";
17
19
  import { tryExtractJsonObject } from "./json";
20
+ import { buildConceptGraph, expandByGraph, lintConceptGraph, type GraphLintReport } from "./memory-graph";
18
21
 
19
22
  /** On-disk document cap — the distill prompt instructs the model to stay under it. */
20
23
  export const MEMORY_MAX_CHARS = 6_000;
@@ -47,16 +50,206 @@ export async function loadMemory(cwd: string): Promise<string> {
47
50
  }
48
51
  }
49
52
 
53
+ /** Render a single index.md-style section: a `## header` followed by one bullet
54
+ * per concept (`**title**: description`), with the concept body indented beneath. */
55
+ function renderConceptSection(header: string, list: { title: string; description: string; body: string }[]): string {
56
+ const lines = [`## ${header}`];
57
+ for (const c of list) {
58
+ lines.push(`- **${c.title}**${c.description ? `: ${c.description}` : ""}`);
59
+ if (c.body) {
60
+ for (const bodyLine of c.body.split("\n")) lines.push(` ${bodyLine}`);
61
+ }
62
+ }
63
+ return lines.join("\n");
64
+ }
65
+
66
+ /** A loaded OKF concept: frontmatter fields + body + bundle-relative path. */
67
+ export interface Concept {
68
+ type: string;
69
+ title: string;
70
+ description: string;
71
+ body: string;
72
+ tags: string[];
73
+ /** high | medium | low — distiller defaults to "high"; drives core selection. */
74
+ confidence: string;
75
+ /** Bundle-relative path, e.g. `commands/bun-test.md`. */
76
+ relPath: string;
77
+ }
78
+
79
+ /** Read every concept document in the bundle into structured `Concept`s. Reserved
80
+ * files (index.md/log.md) and raw/ payloads are skipped; unparseable or
81
+ * frontmatter-less files are ignored (lenient consumption). */
82
+ export async function loadConcepts(cwd: string): Promise<Concept[]> {
83
+ return loadConceptsFromBundle(path.join(cwd, ".jeo", "memory"));
84
+ }
85
+
86
+ /** Lint the concept bundle's cross-link graph (Sprint 04): orphan concepts,
87
+ * broken links, and duplicate-title merge candidates. Advisory only — mirrors
88
+ * llm-wiki's lint pass. Returns empty lists for an empty/absent bundle. */
89
+ export async function lintMemoryBundle(cwd: string): Promise<GraphLintReport> {
90
+ const concepts = await loadConcepts(cwd);
91
+ return lintConceptGraph(concepts, buildConceptGraph(concepts));
92
+ }
93
+
94
+ async function loadConceptsFromBundle(bundleDir: string): Promise<Concept[]> {
95
+ const files = await findMarkdownFiles(bundleDir);
96
+ const concepts: Concept[] = [];
97
+ for (const file of files) {
98
+ const relPath = path.relative(bundleDir, file).replace(/\\/g, "/");
99
+ if (isReservedFile(relPath)) continue;
100
+ let parsed;
101
+ try {
102
+ parsed = parseConcept(await fs.readFile(file, "utf-8"));
103
+ } catch {
104
+ continue;
105
+ }
106
+ if (!parsed.hasFrontmatter) continue;
107
+ const fm = parsed.frontmatter;
108
+ concepts.push({
109
+ type: (fm.type as string) || "RepoFact",
110
+ title: (fm.title as string) || path.basename(file, ".md"),
111
+ description: (fm.description as string) || "",
112
+ body: parsed.body.trim(),
113
+ tags: Array.isArray(fm.tags) ? fm.tags.filter((t): t is string => typeof t === "string") : [],
114
+ confidence: typeof fm.confidence === "string" ? fm.confidence : "high",
115
+ relPath,
116
+ });
117
+ }
118
+ return concepts;
119
+ }
120
+
121
+ /** Tokenize a free-text query into distinct lowercased keywords (len ≥ 3). */
122
+ function tokenize(query?: string): string[] {
123
+ if (!query) return [];
124
+ return Array.from(new Set((query.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter(t => t.length >= 3)));
125
+ }
126
+
127
+ /** Relevance score of a concept against query tokens. Field weights mirror
128
+ * llm-wiki's retrieval bias (title ≫ tags ≫ type/description ≫ body). 0 = no hit. */
129
+ export function scoreConcept(concept: Concept, tokens: string[]): number {
130
+ if (tokens.length === 0) return 0;
131
+ const title = concept.title.toLowerCase();
132
+ const desc = concept.description.toLowerCase();
133
+ const body = concept.body.toLowerCase();
134
+ const type = concept.type.toLowerCase();
135
+ const tags = concept.tags.map(t => t.toLowerCase());
136
+ let score = 0;
137
+ for (const t of tokens) {
138
+ if (title.includes(t)) score += 5;
139
+ if (tags.some(tag => tag.includes(t))) score += 3;
140
+ if (type.includes(t)) score += 2;
141
+ if (desc.includes(t)) score += 2;
142
+ if (body.includes(t)) score += 1;
143
+ }
144
+ return score;
145
+ }
146
+
147
+ /** Search the bundle's concepts for a query, returning the relevant ones (score > 0)
148
+ * highest-score first. A type/tags/title/body keyword match all contribute. */
149
+ export function searchConcepts(concepts: Concept[], query: string): { concept: Concept; score: number }[] {
150
+ const tokens = tokenize(query);
151
+ return concepts
152
+ .map(concept => ({ concept, score: scoreConcept(concept, tokens) }))
153
+ .filter(r => r.score > 0)
154
+ .sort((a, b) => b.score - a.score);
155
+ }
156
+
157
+ /** Priority order for injection: high-confidence "core" concepts first, then by
158
+ * query relevance (descending), then concepts the relevant ones LINK TO (1-hop
159
+ * graph expansion — Sprint 04: a directly-hit concept pulls its neighbours in as
160
+ * context ahead of unrelated noise), preserving input order as a stable tiebreak. */
161
+ function priorityOrder(concepts: Concept[], query?: string): Concept[] {
162
+ const tokens = tokenize(query);
163
+ // 1-hop graph expansion: seed from concepts the query directly hits, then mark
164
+ // their link-neighbours as "related" so they outrank unrelated zero-score noise.
165
+ const related = new Set<string>();
166
+ if (tokens.length > 0) {
167
+ const graph = buildConceptGraph(concepts);
168
+ const seeds = concepts.filter(c => scoreConcept(c, tokens) > 0).map(c => conceptId(c.relPath));
169
+ for (const id of expandByGraph(seeds, graph, 1)) related.add(id);
170
+ }
171
+ return concepts
172
+ .map((concept, i) => ({
173
+ concept,
174
+ i,
175
+ core: concept.confidence === "high",
176
+ score: scoreConcept(concept, tokens),
177
+ related: related.has(conceptId(concept.relPath)),
178
+ }))
179
+ .sort((a, b) => {
180
+ if (a.core !== b.core) return a.core ? -1 : 1;
181
+ if (b.score !== a.score) return b.score - a.score;
182
+ if (a.related !== b.related) return a.related ? -1 : 1;
183
+ return a.i - b.i;
184
+ })
185
+ .map(s => s.concept);
186
+ }
187
+
188
+ /** Group items by their `type` into ordered `{ header, list }` sections: TYPE_LAYOUT
189
+ * order first, then any unknown types under their raw type name (lenient). The one
190
+ * place that encodes the section ordering — shared by render and index. */
191
+ function groupByTypeLayout<T extends { type: string }>(items: T[]): { header: string; list: T[] }[] {
192
+ const byType = new Map<string, T[]>();
193
+ for (const it of items) {
194
+ const list = byType.get(it.type) ?? [];
195
+ list.push(it);
196
+ byType.set(it.type, list);
197
+ }
198
+ const sections: { header: string; list: T[] }[] = [];
199
+ const rendered = new Set<string>();
200
+ for (const { type, header } of TYPE_LAYOUT) {
201
+ rendered.add(type);
202
+ const list = byType.get(type);
203
+ if (list && list.length > 0) sections.push({ header, list });
204
+ }
205
+ for (const [type, list] of byType) {
206
+ if (rendered.has(type) || list.length === 0) continue;
207
+ sections.push({ header: type, list });
208
+ }
209
+ return sections;
210
+ }
211
+
212
+ /** Render a set of concepts as a compact markdown block grouped by type in
213
+ * TYPE_LAYOUT order, with any unknown types appended under their raw type name. */
214
+ function renderConcepts(concepts: Concept[]): string {
215
+ return groupByTypeLayout(concepts)
216
+ .map(({ header, list }) => renderConceptSection(header, list))
217
+ .join("\n\n");
218
+ }
219
+
220
+ /** Greedily select concepts (in priority order) whose grouped render stays within
221
+ * `budget` chars, dropping the lowest-priority concepts first. At least the
222
+ * top-priority concept is always kept (the framing/backstop cap still applies). */
223
+ function selectWithinBudget(concepts: Concept[], query: string | undefined, budget: number): Concept[] {
224
+ const ordered = priorityOrder(concepts, query);
225
+ const selected: Concept[] = [];
226
+ for (const c of ordered) {
227
+ if (renderConcepts([...selected, c]).length <= budget) selected.push(c);
228
+ }
229
+ if (selected.length === 0 && ordered.length > 0) selected.push(ordered[0]!);
230
+ return selected;
231
+ }
232
+
50
233
  /** System-prompt block carrying prior-session learnings; "" when empty or disabled.
234
+ * Selection (Sprint 03): always-included high-confidence core + concepts most
235
+ * relevant to `query` (the current task), chosen whole within MEMORY_INJECT_MAX_CHARS
236
+ * (lowest-priority dropped first) — never a mid-concept string truncation. Falls
237
+ * back to the legacy single MEMORY.md doc when no concept bundle exists.
51
238
  * The memory text is MODEL-DISTILLED from session transcripts (which include tool
52
239
  * outputs — file contents, web results), so it is injection-hardened like subagent
53
240
  * reports: tag-breakout sequences are neutralized and the block is framed as DATA. */
54
- export async function memoryPromptSection(cwd: string): Promise<string> {
241
+ export async function memoryPromptSection(cwd: string, query?: string): Promise<string> {
55
242
  if (jeoEnv("NO_MEMORY") === "1") return "";
56
- let memory = await loadMemory(cwd);
243
+ // Prefer the OKF concept bundle (budget-selected); fall back to legacy MEMORY.md.
244
+ const concepts = await loadConcepts(cwd);
245
+ let memory = concepts.length > 0
246
+ ? renderConcepts(selectWithinBudget(concepts, query, MEMORY_INJECT_MAX_CHARS))
247
+ : await loadMemory(cwd);
57
248
  if (!memory) return "";
249
+ // Backstop: legacy MEMORY.md is a single blob (not concept-selectable), and a
250
+ // pathological single concept can exceed the budget — hard-cap either way.
58
251
  if (memory.length > MEMORY_INJECT_MAX_CHARS) {
59
- memory = memory.slice(0, MEMORY_INJECT_MAX_CHARS) + "\n…(memory truncated — full doc in .jeo/memory/MEMORY.md)";
252
+ memory = memory.slice(0, MEMORY_INJECT_MAX_CHARS) + "\n…(memory truncated — full doc in .jeo/memory/)";
60
253
  }
61
254
  // Neutralize the fence tags so distilled content can never close the block and
62
255
  // smuggle instruction-shaped text into the bare system prompt.
@@ -120,33 +313,20 @@ async function findMarkdownFiles(dir: string): Promise<string[]> {
120
313
  }
121
314
 
122
315
  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
- }
316
+ const concepts = await loadConceptsFromBundle(bundleDir);
140
317
 
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`;
318
+ // Progressive-disclosure index: a link per concept plus its one-line description,
319
+ // grouped by type (TYPE_LAYOUT order first, then any unknown types — lenient).
320
+ const section = (header: string, list: Concept[]): string => {
321
+ let out = `## ${header}\n`;
146
322
  for (const c of list) {
147
- body += `- [${c.title}](/${c.relPath.replace(/\\/g, "/")})\n`;
323
+ out += `- [${c.title}](/${c.relPath})${c.description ? ` — ${c.description}` : ""}\n`;
148
324
  }
149
- body += "\n";
325
+ return out + "\n";
326
+ };
327
+ let body = "# Index\n\n";
328
+ for (const { header, list } of groupByTypeLayout(concepts)) {
329
+ body += section(header, list);
150
330
  }
151
331
 
152
332
  const indexContent = serializeConcept({ okf_version: "0.1" }, body.trim());
@@ -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
- const memoryBlock = await memoryPromptSection(cwd);
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
- private renderPlan(color: boolean): string[] {
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
- finish(reply: string): void {
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).