jeo-code 0.6.15 → 0.6.17
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 +12 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/AGENTS.md +3 -1
- package/src/agent/memory-graph.ts +198 -0
- package/src/agent/memory.ts +166 -3
- package/src/cli/runner.ts +9 -0
- package/src/commands/memory-migrate.ts +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,18 @@ 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.17] - 2026-06-17
|
|
10
|
+
_Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`jeo memory-migrate` — legacy memory → OKF bundle migration (OKF Sprint 05).** A one-shot, idempotent migration converts the legacy single-doc `.jeo/memory/MEMORY.md` into the type-partitioned OKF concept bundle: `parseLegacyMemory` maps each `## heading` to a concept type (commands/gotchas/preferences/repo-facts, unknown → RepoFact) and splits top-level bullets into concepts (`**title**: description` form recognized, indented continuation lines become the body — lossless), then `migrateLegacyMemory` writes each concept atomically under `facts/`/`commands/`/`gotchas/`/`preferences/`, (re)builds `index.md`/`log.md`, and renames the legacy doc to `MEMORY.md.bak` for rollback. Re-running is a no-op once the bundle has concepts. The bundle is the default read path; `JEO_MEMORY_LEGACY=1` is a new rollback toggle that ignores the bundle and reads the legacy doc (or its `.bak` backup) through the same injection-hardening, while `JEO_NO_MEMORY=1` still wins over everything.
|
|
14
|
+
|
|
15
|
+
## [0.6.16] - 2026-06-17
|
|
16
|
+
_OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional._
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **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.
|
|
20
|
+
|
|
9
21
|
## [0.6.15] - 2026-06-17
|
|
10
22
|
_Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt._
|
|
11
23
|
|
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.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
162
|
+
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
161
163
|
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
162
164
|
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
163
165
|
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
164
|
-
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
165
|
-
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
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.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
162
|
+
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
161
163
|
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
162
164
|
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
163
165
|
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
164
|
-
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
165
|
-
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
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.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
162
|
+
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
161
163
|
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
162
164
|
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
163
165
|
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
164
|
-
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
165
|
-
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
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.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
162
|
+
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
161
163
|
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
162
164
|
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
163
165
|
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
164
|
-
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
165
|
-
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
166
166
|
|
|
167
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
168
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/AGENTS.md
CHANGED
|
@@ -17,7 +17,9 @@ The core runtime loop, tool registry, session management, and state persistence
|
|
|
17
17
|
| `hooks.ts` | Brief description of purpose |
|
|
18
18
|
| `json.ts` | Brief description of purpose |
|
|
19
19
|
| `loop.ts` | The primary execution loop orchestrating model calls and tool execution |
|
|
20
|
-
| `memory.ts` |
|
|
20
|
+
| `memory.ts` | OKF concept-bundle memory: session distill, query-aware budget injection, legacy MEMORY.md migration (`migrateLegacyMemory`) + `JEO_MEMORY_LEGACY` rollback toggle |
|
|
21
|
+
| `memory-okf.ts` | OKF v0.1 format layer: frontmatter parse/serialize, concept IDs, conformance validation |
|
|
22
|
+
| `memory-graph.ts` | Concept cross-link graph: build/expand (1-hop search), broken-link-tolerant lint, optional graphify detection |
|
|
21
23
|
| `model-recency.ts` | Brief description of purpose |
|
|
22
24
|
| `output-minimizer.ts` | Brief description of purpose |
|
|
23
25
|
| `output-util.ts` | Brief description of purpose |
|
|
@@ -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
|
+
}
|
package/src/agent/memory.ts
CHANGED
|
@@ -15,8 +15,9 @@ import { spawn as nodeSpawn } from "node:child_process";
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { callLlm, type Message } from "./loop";
|
|
17
17
|
import { jeoEnv } from "../util/env";
|
|
18
|
-
import { parseConcept, serializeConcept, slugify, isReservedFile } from "./memory-okf";
|
|
18
|
+
import { parseConcept, serializeConcept, slugify, isReservedFile, conceptId } from "./memory-okf";
|
|
19
19
|
import { tryExtractJsonObject } from "./json";
|
|
20
|
+
import { buildConceptGraph, expandByGraph, lintConceptGraph, type GraphLintReport } from "./memory-graph";
|
|
20
21
|
|
|
21
22
|
/** On-disk document cap — the distill prompt instructs the model to stay under it. */
|
|
22
23
|
export const MEMORY_MAX_CHARS = 6_000;
|
|
@@ -49,6 +50,129 @@ export async function loadMemory(cwd: string): Promise<string> {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** A concept extracted from a legacy single-doc MEMORY.md during migration. */
|
|
54
|
+
export interface MigratedConcept {
|
|
55
|
+
type: string;
|
|
56
|
+
title: string;
|
|
57
|
+
description: string;
|
|
58
|
+
body: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Map a legacy `## heading` to a jeo concept type. Lenient keyword match —
|
|
62
|
+
* unknown headings default to RepoFact so nothing is dropped. */
|
|
63
|
+
function headingToType(heading: string): string {
|
|
64
|
+
const h = heading.toLowerCase();
|
|
65
|
+
if (/\bcommand/.test(h)) return "Command";
|
|
66
|
+
if (/gotcha|pitfall|caveat/.test(h)) return "Gotcha";
|
|
67
|
+
if (/pref/.test(h)) return "UserPreference";
|
|
68
|
+
if (/repo|fact/.test(h)) return "RepoFact";
|
|
69
|
+
return "RepoFact";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parse a legacy 4-heading MEMORY.md into concepts: each `## heading` sets the
|
|
73
|
+
* type, each top-level bullet becomes a concept (`**title**: description` form
|
|
74
|
+
* recognized, indented continuation lines become the body). Lossless: a plain
|
|
75
|
+
* bullet keeps its whole text as the title. */
|
|
76
|
+
export function parseLegacyMemory(doc: string): MigratedConcept[] {
|
|
77
|
+
const concepts: MigratedConcept[] = [];
|
|
78
|
+
let currentType = "RepoFact";
|
|
79
|
+
let cur: MigratedConcept | null = null;
|
|
80
|
+
const flush = () => {
|
|
81
|
+
if (cur) {
|
|
82
|
+
cur.body = cur.body.replace(/\n+$/, "");
|
|
83
|
+
concepts.push(cur);
|
|
84
|
+
cur = null;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
for (const line of doc.split("\n")) {
|
|
88
|
+
const heading = line.match(/^#{1,6}\s+(.*)$/);
|
|
89
|
+
if (heading) {
|
|
90
|
+
flush();
|
|
91
|
+
currentType = headingToType(heading[1]!.trim());
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
|
|
95
|
+
if (bullet) {
|
|
96
|
+
flush();
|
|
97
|
+
const text = bullet[1]!.trim();
|
|
98
|
+
const bold = text.match(/^\*\*(.+?)\*\*\s*:?\s*(.*)$/);
|
|
99
|
+
cur = bold
|
|
100
|
+
? { type: currentType, title: bold[1]!.trim(), description: bold[2]!.trim(), body: "" }
|
|
101
|
+
: { type: currentType, title: text, description: "", body: "" };
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Continuation line (typically 2-space indented) belongs to the open concept.
|
|
105
|
+
if (cur && line.trim() !== "") {
|
|
106
|
+
cur.body += (cur.body ? "\n" : "") + line.replace(/^ {2}/, "");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
flush();
|
|
110
|
+
return concepts.filter(c => c.title);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Outcome of a one-shot legacy → OKF bundle migration. */
|
|
114
|
+
export interface MigrationResult {
|
|
115
|
+
migrated: boolean;
|
|
116
|
+
conceptCount: number;
|
|
117
|
+
/** Why nothing was migrated (already a bundle / no legacy doc / nothing parsed). */
|
|
118
|
+
skipped?: string;
|
|
119
|
+
/** Where the legacy MEMORY.md was preserved for rollback, if migrated. */
|
|
120
|
+
backupPath?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Migrate a legacy single-doc `.jeo/memory/MEMORY.md` into the OKF concept bundle.
|
|
125
|
+
* One-shot and IDEMPOTENT: a bundle that already holds concept docs is left
|
|
126
|
+
* untouched. On success each legacy bullet becomes a type-partitioned concept,
|
|
127
|
+
* index.md/log.md are (re)built, and the legacy doc is renamed to `MEMORY.md.bak`
|
|
128
|
+
* so the active path is the bundle while a rollback copy survives.
|
|
129
|
+
*/
|
|
130
|
+
export async function migrateLegacyMemory(cwd: string): Promise<MigrationResult> {
|
|
131
|
+
const bundleDir = path.join(cwd, ".jeo", "memory");
|
|
132
|
+
// Idempotent: an existing concept bundle wins — never double-migrate.
|
|
133
|
+
if ((await loadConcepts(cwd)).length > 0) {
|
|
134
|
+
return { migrated: false, conceptCount: 0, skipped: "bundle already has concepts" };
|
|
135
|
+
}
|
|
136
|
+
const doc = await loadMemory(cwd);
|
|
137
|
+
if (!doc) return { migrated: false, conceptCount: 0, skipped: "no legacy MEMORY.md to migrate" };
|
|
138
|
+
const parsed = parseLegacyMemory(doc);
|
|
139
|
+
if (parsed.length === 0) return { migrated: false, conceptCount: 0, skipped: "no concepts parsed from MEMORY.md" };
|
|
140
|
+
|
|
141
|
+
await fs.mkdir(bundleDir, { recursive: true });
|
|
142
|
+
const written: { title: string; type: string }[] = [];
|
|
143
|
+
const usedSlugs = new Set<string>();
|
|
144
|
+
for (const c of parsed) {
|
|
145
|
+
const dir = DIR_BY_TYPE[c.type] ?? "facts";
|
|
146
|
+
await fs.mkdir(path.join(bundleDir, dir), { recursive: true });
|
|
147
|
+
let slug = slugify(c.title);
|
|
148
|
+
let suffix = 1;
|
|
149
|
+
while (usedSlugs.has(`${dir}/${slug}`)) slug = `${slugify(c.title)}-${suffix++}`;
|
|
150
|
+
usedSlugs.add(`${dir}/${slug}`);
|
|
151
|
+
const frontmatter = {
|
|
152
|
+
type: c.type,
|
|
153
|
+
title: c.title,
|
|
154
|
+
description: c.description,
|
|
155
|
+
tags: [] as string[],
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
confidence: "high",
|
|
158
|
+
last_verified: new Date().toISOString().split("T")[0]!,
|
|
159
|
+
links: [] as string[],
|
|
160
|
+
};
|
|
161
|
+
const serialized = serializeConcept(frontmatter, c.body);
|
|
162
|
+
const fullPath = path.join(bundleDir, dir, `${slug}.md`);
|
|
163
|
+
const tmpPath = `${fullPath}.tmp-${process.pid}`;
|
|
164
|
+
await fs.writeFile(tmpPath, serialized, "utf-8");
|
|
165
|
+
await fs.rename(tmpPath, fullPath);
|
|
166
|
+
written.push({ title: c.title, type: c.type });
|
|
167
|
+
}
|
|
168
|
+
await rebuildIndex(bundleDir);
|
|
169
|
+
await updateLog(bundleDir, written);
|
|
170
|
+
// Preserve the legacy doc as a rollback backup, off the active read path.
|
|
171
|
+
const backupPath = `${memoryFilePath(cwd)}.bak`;
|
|
172
|
+
await fs.rename(memoryFilePath(cwd), backupPath).catch(() => {});
|
|
173
|
+
return { migrated: true, conceptCount: written.length, backupPath };
|
|
174
|
+
}
|
|
175
|
+
|
|
52
176
|
/** Render a single index.md-style section: a `## header` followed by one bullet
|
|
53
177
|
* per concept (`**title**: description`), with the concept body indented beneath. */
|
|
54
178
|
function renderConceptSection(header: string, list: { title: string; description: string; body: string }[]): string {
|
|
@@ -82,6 +206,14 @@ export async function loadConcepts(cwd: string): Promise<Concept[]> {
|
|
|
82
206
|
return loadConceptsFromBundle(path.join(cwd, ".jeo", "memory"));
|
|
83
207
|
}
|
|
84
208
|
|
|
209
|
+
/** Lint the concept bundle's cross-link graph (Sprint 04): orphan concepts,
|
|
210
|
+
* broken links, and duplicate-title merge candidates. Advisory only — mirrors
|
|
211
|
+
* llm-wiki's lint pass. Returns empty lists for an empty/absent bundle. */
|
|
212
|
+
export async function lintMemoryBundle(cwd: string): Promise<GraphLintReport> {
|
|
213
|
+
const concepts = await loadConcepts(cwd);
|
|
214
|
+
return lintConceptGraph(concepts, buildConceptGraph(concepts));
|
|
215
|
+
}
|
|
216
|
+
|
|
85
217
|
async function loadConceptsFromBundle(bundleDir: string): Promise<Concept[]> {
|
|
86
218
|
const files = await findMarkdownFiles(bundleDir);
|
|
87
219
|
const concepts: Concept[] = [];
|
|
@@ -146,14 +278,31 @@ export function searchConcepts(concepts: Concept[], query: string): { concept: C
|
|
|
146
278
|
}
|
|
147
279
|
|
|
148
280
|
/** Priority order for injection: high-confidence "core" concepts first, then by
|
|
149
|
-
* query relevance (descending),
|
|
281
|
+
* query relevance (descending), then concepts the relevant ones LINK TO (1-hop
|
|
282
|
+
* graph expansion — Sprint 04: a directly-hit concept pulls its neighbours in as
|
|
283
|
+
* context ahead of unrelated noise), preserving input order as a stable tiebreak. */
|
|
150
284
|
function priorityOrder(concepts: Concept[], query?: string): Concept[] {
|
|
151
285
|
const tokens = tokenize(query);
|
|
286
|
+
// 1-hop graph expansion: seed from concepts the query directly hits, then mark
|
|
287
|
+
// their link-neighbours as "related" so they outrank unrelated zero-score noise.
|
|
288
|
+
const related = new Set<string>();
|
|
289
|
+
if (tokens.length > 0) {
|
|
290
|
+
const graph = buildConceptGraph(concepts);
|
|
291
|
+
const seeds = concepts.filter(c => scoreConcept(c, tokens) > 0).map(c => conceptId(c.relPath));
|
|
292
|
+
for (const id of expandByGraph(seeds, graph, 1)) related.add(id);
|
|
293
|
+
}
|
|
152
294
|
return concepts
|
|
153
|
-
.map((concept, i) => ({
|
|
295
|
+
.map((concept, i) => ({
|
|
296
|
+
concept,
|
|
297
|
+
i,
|
|
298
|
+
core: concept.confidence === "high",
|
|
299
|
+
score: scoreConcept(concept, tokens),
|
|
300
|
+
related: related.has(conceptId(concept.relPath)),
|
|
301
|
+
}))
|
|
154
302
|
.sort((a, b) => {
|
|
155
303
|
if (a.core !== b.core) return a.core ? -1 : 1;
|
|
156
304
|
if (b.score !== a.score) return b.score - a.score;
|
|
305
|
+
if (a.related !== b.related) return a.related ? -1 : 1;
|
|
157
306
|
return a.i - b.i;
|
|
158
307
|
})
|
|
159
308
|
.map(s => s.concept);
|
|
@@ -214,12 +363,26 @@ function selectWithinBudget(concepts: Concept[], query: string | undefined, budg
|
|
|
214
363
|
* reports: tag-breakout sequences are neutralized and the block is framed as DATA. */
|
|
215
364
|
export async function memoryPromptSection(cwd: string, query?: string): Promise<string> {
|
|
216
365
|
if (jeoEnv("NO_MEMORY") === "1") return "";
|
|
366
|
+
// Rollback toggle (Sprint 05): JEO_MEMORY_LEGACY=1 forces the legacy single-doc
|
|
367
|
+
// path, ignoring any concept bundle — reads MEMORY.md, or its migration backup.
|
|
368
|
+
if (jeoEnv("MEMORY_LEGACY") === "1") {
|
|
369
|
+
let memory = await loadMemory(cwd);
|
|
370
|
+
if (!memory) memory = (await fs.readFile(`${memoryFilePath(cwd)}.bak`, "utf-8").catch(() => "")).trim();
|
|
371
|
+
return memory ? frameMemory(memory) : "";
|
|
372
|
+
}
|
|
217
373
|
// Prefer the OKF concept bundle (budget-selected); fall back to legacy MEMORY.md.
|
|
218
374
|
const concepts = await loadConcepts(cwd);
|
|
219
375
|
let memory = concepts.length > 0
|
|
220
376
|
? renderConcepts(selectWithinBudget(concepts, query, MEMORY_INJECT_MAX_CHARS))
|
|
221
377
|
: await loadMemory(cwd);
|
|
222
378
|
if (!memory) return "";
|
|
379
|
+
return frameMemory(memory);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Wrap distilled memory text in the hardened `<project_memory>` block: hard char
|
|
383
|
+
* cap, fence-tag neutralization, and DATA framing. Shared by the bundle path and
|
|
384
|
+
* the legacy/rollback path so neither can bypass the injection-hardening. */
|
|
385
|
+
function frameMemory(memory: string): string {
|
|
223
386
|
// Backstop: legacy MEMORY.md is a single blob (not concept-selectable), and a
|
|
224
387
|
// pathological single concept can exceed the budget — hard-cap either way.
|
|
225
388
|
if (memory.length > MEMORY_INJECT_MAX_CHARS) {
|
package/src/cli/runner.ts
CHANGED
|
@@ -146,6 +146,15 @@ export const COMMANDS: readonly CommandSpec[] = [
|
|
|
146
146
|
return args => m.runMemoryDistillCommand(args);
|
|
147
147
|
},
|
|
148
148
|
},
|
|
149
|
+
{
|
|
150
|
+
name: "memory-migrate",
|
|
151
|
+
summary: "Migrate a legacy MEMORY.md into the OKF concept bundle (one-shot, idempotent).",
|
|
152
|
+
usage: "memory-migrate",
|
|
153
|
+
loader: async () => {
|
|
154
|
+
const m = await import("../commands/memory-migrate");
|
|
155
|
+
return args => m.runMemoryMigrateCommand(args);
|
|
156
|
+
},
|
|
157
|
+
},
|
|
149
158
|
{
|
|
150
159
|
name: "state",
|
|
151
160
|
summary: "Read or update workflow state receipts under .jeo/state (gjc-state parity).",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jeo memory-migrate` — one-shot, idempotent migration of a legacy single-doc
|
|
3
|
+
* `.jeo/memory/MEMORY.md` into the OKF concept bundle (Sprint 05).
|
|
4
|
+
*
|
|
5
|
+
* Safe to re-run: if the bundle already holds concepts it is left untouched.
|
|
6
|
+
* The legacy doc is preserved as `MEMORY.md.bak` for rollback; set
|
|
7
|
+
* `JEO_MEMORY_LEGACY=1` to read that backup again if a rollback is needed.
|
|
8
|
+
*/
|
|
9
|
+
import { migrateLegacyMemory } from "../agent/memory";
|
|
10
|
+
|
|
11
|
+
export async function runMemoryMigrateCommand(_args: string[]): Promise<void> {
|
|
12
|
+
const result = await migrateLegacyMemory(process.cwd());
|
|
13
|
+
if (result.migrated) {
|
|
14
|
+
console.log(`migrated ${result.conceptCount} concept(s) from MEMORY.md → OKF bundle (.jeo/memory/).`);
|
|
15
|
+
if (result.backupPath) console.log(`legacy doc preserved at ${result.backupPath} (rollback: JEO_MEMORY_LEGACY=1).`);
|
|
16
|
+
} else {
|
|
17
|
+
console.log(`nothing to migrate — ${result.skipped}.`);
|
|
18
|
+
}
|
|
19
|
+
}
|