open-memex 0.7.0-alpha.1 → 0.7.0-alpha.13
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/AGENTS.md +23 -5
- package/CHANGELOG.md +88 -0
- package/README.md +46 -15
- package/README.zh-CN.md +27 -14
- package/dist/.build-stamp +1 -0
- package/dist/capture/keywords.js +41 -8
- package/dist/cli.js +259 -40
- package/dist/config.js +102 -18
- package/dist/distill-agents.js +9 -1
- package/dist/doctor.js +37 -47
- package/dist/export.js +12 -2
- package/dist/index.js +4 -2
- package/dist/init.js +23 -6
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +176 -0
- package/dist/mcp.js +21 -5
- package/dist/paths.js +34 -2
- package/dist/retrieve/display.js +161 -0
- package/dist/retrieve/inject.js +1 -1
- package/dist/retrieve/search.js +44 -5
- package/dist/review.js +9 -3
- package/dist/scope.js +17 -0
- package/dist/store/db.js +11 -9
- package/dist/store/lifecycle.js +20 -4
- package/dist/store/markdown.js +53 -7
- package/dist/store/migrate.js +14 -3
- package/dist/store/sync.js +5 -3
- package/dist/store/v2migrate.js +2 -2
- package/dist/submit.js +13 -5
- package/dist/tools/memory.js +28 -0
- package/dist/tools/ops.js +127 -16
- package/docs/CURATOR.md +7 -3
- package/docs/V2-DESIGN.md +299 -32
- package/docs/memory-visibility-design.md +130 -0
- package/package.json +2 -2
- package/scripts/build-stamp.mjs +63 -0
- package/scripts/smoke-mcp.ts +47 -1
- package/scripts/smoke-pure.ts +227 -3
- package/scripts/test-full.ts +435 -5
- package/skills/open-memex/SKILL.md +53 -1
- package/src/capture/keywords.ts +56 -6
- package/src/cli.ts +282 -39
- package/src/config.ts +104 -17
- package/src/distill-agents.ts +19 -0
- package/src/doctor.ts +41 -42
- package/src/export.ts +14 -2
- package/src/index.ts +4 -2
- package/src/init.ts +25 -6
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +242 -0
- package/src/mcp.ts +21 -5
- package/src/paths.ts +35 -1
- package/src/retrieve/display.ts +174 -0
- package/src/retrieve/inject.ts +1 -1
- package/src/retrieve/search.ts +63 -6
- package/src/review.ts +9 -2
- package/src/scope.ts +18 -0
- package/src/store/db.ts +11 -9
- package/src/store/lifecycle.ts +24 -3
- package/src/store/markdown.ts +60 -7
- package/src/store/migrate.ts +15 -2
- package/src/store/sync.ts +6 -3
- package/src/store/v2migrate.ts +2 -1
- package/src/submit.ts +12 -4
- package/src/tools/memory.ts +28 -0
- package/src/tools/ops.ts +147 -17
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display helpers (D68): how memory provenance, age, and state are
|
|
3
|
+
* rendered on the user-facing surfaces — the `memory_list` inventory an
|
|
4
|
+
* agent shows its user, and the `open-memex inventory` reports.
|
|
5
|
+
*
|
|
6
|
+
* Two rules from the 3-C design (v2) live here:
|
|
7
|
+
* - The list format is STRUCTURED data (number + id + created= + source=
|
|
8
|
+
* + state tags). The plain-language renderings ("you said this",
|
|
9
|
+
* "3 days ago") live in the guidance surfaces (tool descriptions,
|
|
10
|
+
* SKILL.md, MCP handshake) so an agent rephrases them in the
|
|
11
|
+
* conversation's language — the raw enums never get localized or
|
|
12
|
+
* reworded inside the data itself.
|
|
13
|
+
* - Nothing truncates silently: callers pair these lines with the true
|
|
14
|
+
* totals so a cut list says so.
|
|
15
|
+
*/
|
|
16
|
+
/** Human age for a saved memory: "just now", "3 days ago", else a date. */
|
|
17
|
+
export function relativeTime(ts, now = Date.now()) {
|
|
18
|
+
if (!ts || Number.isNaN(ts))
|
|
19
|
+
return "";
|
|
20
|
+
const diff = now - ts;
|
|
21
|
+
if (diff < 0)
|
|
22
|
+
return "just now";
|
|
23
|
+
const minute = 60_000;
|
|
24
|
+
const hour = 60 * minute;
|
|
25
|
+
const day = 24 * hour;
|
|
26
|
+
if (diff < minute)
|
|
27
|
+
return "just now";
|
|
28
|
+
if (diff < hour) {
|
|
29
|
+
const n = Math.floor(diff / minute);
|
|
30
|
+
return `${n} minute${n === 1 ? "" : "s"} ago`;
|
|
31
|
+
}
|
|
32
|
+
if (diff < day) {
|
|
33
|
+
const n = Math.floor(diff / hour);
|
|
34
|
+
return `${n} hour${n === 1 ? "" : "s"} ago`;
|
|
35
|
+
}
|
|
36
|
+
if (diff < 30 * day) {
|
|
37
|
+
const n = Math.floor(diff / day);
|
|
38
|
+
return `${n} day${n === 1 ? "" : "s"} ago`;
|
|
39
|
+
}
|
|
40
|
+
const d = new Date(ts);
|
|
41
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
42
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
43
|
+
return `on ${d.getFullYear()}-${mm}-${dd}`;
|
|
44
|
+
}
|
|
45
|
+
/** Compact age token for the structured list format: `3d`, `12h`, `now`. */
|
|
46
|
+
export function ageToken(ts, now = Date.now()) {
|
|
47
|
+
if (!ts || Number.isNaN(ts))
|
|
48
|
+
return "?";
|
|
49
|
+
const diff = now - ts;
|
|
50
|
+
if (diff < 60_000)
|
|
51
|
+
return "now";
|
|
52
|
+
if (diff < 3_600_000)
|
|
53
|
+
return `${Math.floor(diff / 60_000)}m`;
|
|
54
|
+
if (diff < 86_400_000)
|
|
55
|
+
return `${Math.floor(diff / 3_600_000)}h`;
|
|
56
|
+
if (diff < 60 * 86_400_000)
|
|
57
|
+
return `${Math.floor(diff / 86_400_000)}d`;
|
|
58
|
+
const d = new Date(ts);
|
|
59
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
60
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
61
|
+
return `${d.getFullYear()}-${mm}-${dd}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Provenance in the user's words (D68). Used by prose surfaces (HTML
|
|
65
|
+
* report); the structured list keeps the raw `source=` enum instead.
|
|
66
|
+
* The distinction that matters to the user is *whose words these were* —
|
|
67
|
+
* said outright, asked to be saved, auto-captured, or inferred by the
|
|
68
|
+
* agent and possibly wrong.
|
|
69
|
+
*/
|
|
70
|
+
export function sourceLabel(source) {
|
|
71
|
+
switch (source) {
|
|
72
|
+
case "user":
|
|
73
|
+
return "you said this";
|
|
74
|
+
case "cli":
|
|
75
|
+
return "you saved this from the command line";
|
|
76
|
+
case "tool":
|
|
77
|
+
return "you asked me to save this";
|
|
78
|
+
case "keyword":
|
|
79
|
+
return "auto-captured from your own wording";
|
|
80
|
+
case "inference":
|
|
81
|
+
return "I inferred this — not in your words, check me";
|
|
82
|
+
case "import":
|
|
83
|
+
return "imported from another machine";
|
|
84
|
+
default:
|
|
85
|
+
return source ? `saved (${source})` : "saved";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Review state in plain words for project scopes (D68). Personal
|
|
90
|
+
* memories are always `draft` by design; callers must not show this
|
|
91
|
+
* for them — "not yet shared" would be alarming noise there.
|
|
92
|
+
*/
|
|
93
|
+
export function reviewStateLabel(reviewState) {
|
|
94
|
+
switch (reviewState) {
|
|
95
|
+
case "draft":
|
|
96
|
+
return "still in your outbox — not in the repo yet";
|
|
97
|
+
case "proposed":
|
|
98
|
+
return "shared with the project, waiting for review";
|
|
99
|
+
case "approved":
|
|
100
|
+
return "approved, not yet published";
|
|
101
|
+
case "published":
|
|
102
|
+
return "published to the repo";
|
|
103
|
+
case "rejected":
|
|
104
|
+
return "reviewed and rejected";
|
|
105
|
+
default:
|
|
106
|
+
return reviewState ?? "draft";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Escape user content for HTML. Memory text is arbitrary user input. */
|
|
110
|
+
export function escapeHtml(s) {
|
|
111
|
+
return s
|
|
112
|
+
.replace(/&/g, "&")
|
|
113
|
+
.replace(/</g, "<")
|
|
114
|
+
.replace(/>/g, ">")
|
|
115
|
+
.replace(/"/g, """)
|
|
116
|
+
.replace(/'/g, "'");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* One structured inventory line (D68):
|
|
120
|
+
* 3. [fact] id=01K6… created=3d source=user [draft] — body text
|
|
121
|
+
* Number and id always appear together: the number is for the user's
|
|
122
|
+
* mouth ("delete #3"), the id is the machine contract. `created=` is
|
|
123
|
+
* when the memory was learned; `updated=` appears only when it moved
|
|
124
|
+
* materially later (supersede/promote touch updated_at).
|
|
125
|
+
*
|
|
126
|
+
* D70: `index` must be a running counter across every section of one
|
|
127
|
+
* listing — a per-section counter produces two "#1"s in a single
|
|
128
|
+
* `scope: both` output, which makes "delete #3" a guess.
|
|
129
|
+
*/
|
|
130
|
+
export function formatInventoryLine(index, hit, personalKey, now = Date.now()) {
|
|
131
|
+
const parts = [
|
|
132
|
+
`${index + 1}.`,
|
|
133
|
+
`[${hit.type}]`,
|
|
134
|
+
`id=${hit.id}`,
|
|
135
|
+
`created=${ageToken(hit.created_at ?? hit.updated_at, now)}`,
|
|
136
|
+
`source=${hit.source || "?"}`,
|
|
137
|
+
];
|
|
138
|
+
const created = hit.created_at ?? hit.updated_at ?? 0;
|
|
139
|
+
if (hit.updated_at && hit.updated_at - created >= 86_400_000) {
|
|
140
|
+
parts.push(`updated=${ageToken(hit.updated_at, now)}`);
|
|
141
|
+
}
|
|
142
|
+
if (hit.scope_key !== personalKey) {
|
|
143
|
+
const state = hit.review_state ?? "draft";
|
|
144
|
+
if (state !== "published")
|
|
145
|
+
parts.push(`[${state}]`);
|
|
146
|
+
}
|
|
147
|
+
if (hit.status !== "active")
|
|
148
|
+
parts.push(`[${hit.status}]`);
|
|
149
|
+
const body = hit.snippet.replace(/\s+/g, " ").trim();
|
|
150
|
+
return `${parts.join(" ")} — ${body}`;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Truncation disclosure (D68): a cut list always says so.
|
|
154
|
+
* D70: the wording names the surface that can show everything — the CLI —
|
|
155
|
+
* so a hidden entry is always reachable.
|
|
156
|
+
*/
|
|
157
|
+
export function truncationNote(shown, total, cli = "open-memex") {
|
|
158
|
+
if (total <= shown)
|
|
159
|
+
return null;
|
|
160
|
+
return `… and ${total - shown} more not shown (raise limit up to 100, run \`${cli} list --limit 100\`, or use memory_search to find something specific).`;
|
|
161
|
+
}
|
package/dist/retrieve/inject.js
CHANGED
|
@@ -20,6 +20,6 @@ export function buildContextBlock(scope, cfg) {
|
|
|
20
20
|
for (const m of project)
|
|
21
21
|
lines.push(`- [${m.type}]${hitStateLabel(m)} ${oneLine(m.snippet)}`);
|
|
22
22
|
}
|
|
23
|
-
lines.push("", "Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. Do not mention this block to the user unless asked.");
|
|
23
|
+
lines.push("", "Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. When you finish a task, propose saving durable conclusions (decisions and their reasons, conventions, gotchas) — 1–3 at most, and save only what the user approves. Do not mention this block to the user unless asked.");
|
|
24
24
|
return lines.join("\n");
|
|
25
25
|
}
|
package/dist/retrieve/search.js
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import { db } from "../store/db.js";
|
|
2
2
|
import { toFtsQuery } from "./query.js";
|
|
3
3
|
function toHit(r) {
|
|
4
|
+
const aliases = r.aliases ? r.aliases.split(",").map((a) => a.trim()).filter(Boolean) : [];
|
|
5
|
+
let snippet = r.snippet ?? "";
|
|
6
|
+
// D61: the snippet highlights matches in the body column only. When the
|
|
7
|
+
// match came through an alias (body shows no highlight), say so — the
|
|
8
|
+
// hit otherwise looks unrelated to the query.
|
|
9
|
+
if (aliases.length > 0 && !/\[[^\]]+\]/.test(snippet)) {
|
|
10
|
+
snippet += ` (aka: ${aliases.join(", ")})`;
|
|
11
|
+
}
|
|
4
12
|
return {
|
|
5
13
|
id: r.id,
|
|
6
14
|
scope_key: r.scope_key,
|
|
7
15
|
project_name: r.project_name,
|
|
8
16
|
type: r.type,
|
|
9
17
|
tags: r.tags ? r.tags.split(",").filter(Boolean) : [],
|
|
10
|
-
|
|
18
|
+
aliases,
|
|
19
|
+
snippet,
|
|
11
20
|
score: r.score,
|
|
12
21
|
updated_at: r.updated_at,
|
|
13
22
|
status: r.status,
|
|
14
23
|
review_state: r.review_state ?? "draft",
|
|
24
|
+
source: r.source ?? "",
|
|
25
|
+
created_at: r.created_at ?? r.updated_at ?? 0,
|
|
15
26
|
};
|
|
16
27
|
}
|
|
17
28
|
/**
|
|
@@ -52,7 +63,7 @@ function resolveVisible(rows, limit, stats) {
|
|
|
52
63
|
return cached;
|
|
53
64
|
const r = db()
|
|
54
65
|
.prepare(`SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
55
|
-
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
66
|
+
superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
|
|
56
67
|
FROM memories WHERE id = ?`)
|
|
57
68
|
.get(id);
|
|
58
69
|
if (!r)
|
|
@@ -124,8 +135,8 @@ export function search(query, opts = {}) {
|
|
|
124
135
|
: "";
|
|
125
136
|
const typeFilter = opts.type ? ` AND m.type = ?` : "";
|
|
126
137
|
const sql = `
|
|
127
|
-
SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
|
|
128
|
-
m.status, m.superseded_by, m.review_state,
|
|
138
|
+
SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.updated_at,
|
|
139
|
+
m.status, m.superseded_by, m.review_state, m.source, m.created_at,
|
|
129
140
|
snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
|
|
130
141
|
bm25(memories_fts) AS score
|
|
131
142
|
FROM memories_fts
|
|
@@ -155,7 +166,7 @@ export function list(scopeKey, opts = {}) {
|
|
|
155
166
|
const typeFilter = opts.type ? ` AND type = ?` : "";
|
|
156
167
|
const sql = `
|
|
157
168
|
SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
158
|
-
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
169
|
+
superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
|
|
159
170
|
FROM memories
|
|
160
171
|
WHERE scope_key = ?${typeFilter}
|
|
161
172
|
ORDER BY updated_at DESC
|
|
@@ -169,5 +180,33 @@ export function list(scopeKey, opts = {}) {
|
|
|
169
180
|
.prepare(sql)
|
|
170
181
|
.all(...params);
|
|
171
182
|
const raw = rows.map((r) => ({ ...r, score: 1 }));
|
|
183
|
+
if (opts.include === "all") {
|
|
184
|
+
// D68: the audit view — every row as stored (superseded versions,
|
|
185
|
+
// retracted, archived), no chain collapsing, capped at limit.
|
|
186
|
+
return raw.slice(0, limit).map(toHit);
|
|
187
|
+
}
|
|
172
188
|
return resolveVisible(raw, limit);
|
|
173
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* True totals for the inventory's truncation disclosure (D68). Under
|
|
192
|
+
* `active` the visible set is the newest of each chain, which in a
|
|
193
|
+
* consistent store is exactly the rows whose status is active/deprecated.
|
|
194
|
+
*
|
|
195
|
+
* D70: one deliberate exception — a `superseded` row whose chain target is
|
|
196
|
+
* missing (a dangling pointer, which the read path self-heals with a
|
|
197
|
+
* warning) is returned as a visible hit by resolveVisible but is not
|
|
198
|
+
* counted here, so the total can read one low in that state. Not worth
|
|
199
|
+
* widening the query for a self-healing case; the disclosure stays honest
|
|
200
|
+
* in every state a user can reach deliberately.
|
|
201
|
+
*/
|
|
202
|
+
export function countList(scopeKey, opts = {}) {
|
|
203
|
+
const typeFilter = opts.type ? ` AND type = ?` : "";
|
|
204
|
+
const statusFilter = opts.include === "all" ? "" : ` AND status IN ('active', 'deprecated')`;
|
|
205
|
+
const params = [scopeKey];
|
|
206
|
+
if (opts.type)
|
|
207
|
+
params.push(opts.type);
|
|
208
|
+
const row = db()
|
|
209
|
+
.prepare(`SELECT COUNT(*) AS n FROM memories WHERE scope_key = ?${statusFilter}${typeFilter}`)
|
|
210
|
+
.get(...params);
|
|
211
|
+
return row.n;
|
|
212
|
+
}
|
package/dist/review.js
CHANGED
|
@@ -23,7 +23,7 @@ import * as path from "node:path";
|
|
|
23
23
|
import { loadConfig } from "./config.js";
|
|
24
24
|
import { inRepoMemoriesDirPath, projectRoot } from "./paths.js";
|
|
25
25
|
import { resolveProjectScope } from "./scope.js";
|
|
26
|
-
import { parse as parseMemory, serialize, ulid, writeMemoryFile, } from "./store/markdown.js";
|
|
26
|
+
import { parse as parseMemory, serialize, atomicWriteTextSync, ulid, writeMemoryFile, } from "./store/markdown.js";
|
|
27
27
|
import { findMemoryFile } from "./store/lifecycle.js";
|
|
28
28
|
import { upsertFromFile } from "./store/sync.js";
|
|
29
29
|
/** Best-effort author identity: git user.name, else the OS user. */
|
|
@@ -79,6 +79,12 @@ function copyPersonalToProject(sourceId, srcFm, srcBody, opts = {}) {
|
|
|
79
79
|
id: ulid(),
|
|
80
80
|
scope: "project",
|
|
81
81
|
scope_key: project.key,
|
|
82
|
+
// D64: the copy is headed for the team. Source visibility (usually
|
|
83
|
+
// "private") must not ride along: export excludes private by default,
|
|
84
|
+
// so a private copy would silently vanish from every bundle the team
|
|
85
|
+
// builds. Proposing IS the act of sharing — the copy is internal;
|
|
86
|
+
// the personal original keeps its own visibility, untouched.
|
|
87
|
+
visibility: "internal",
|
|
82
88
|
project_name: project.projectName,
|
|
83
89
|
created_at: now,
|
|
84
90
|
updated_at: now,
|
|
@@ -194,7 +200,7 @@ export function promoteMemory(id, opts = {}) {
|
|
|
194
200
|
},
|
|
195
201
|
],
|
|
196
202
|
};
|
|
197
|
-
|
|
203
|
+
atomicWriteTextSync(mf.filePath, serialize(fm, mf.body));
|
|
198
204
|
upsertFromFile({ fm, body: mf.body, filePath: mf.filePath, mtimeMs: Date.now() });
|
|
199
205
|
return { id, from, to, history: fm.review_history };
|
|
200
206
|
}
|
|
@@ -428,7 +434,7 @@ export function resolveConflict(target) {
|
|
|
428
434
|
if (conflicts.length > 0)
|
|
429
435
|
return { ok: false, filePath, conflicts };
|
|
430
436
|
const fm = merged;
|
|
431
|
-
|
|
437
|
+
atomicWriteTextSync(filePath, serialize(fm, mergedBody));
|
|
432
438
|
upsertFromFile({ fm, body: mergedBody, filePath, mtimeMs: Date.now() });
|
|
433
439
|
return { ok: true, filePath, autoMerged: [...new Set(autoMerged)] };
|
|
434
440
|
}
|
package/dist/scope.js
CHANGED
|
@@ -57,6 +57,23 @@ export function resolveCwdScope(worktree) {
|
|
|
57
57
|
projectName = "workspace";
|
|
58
58
|
return scopeFromSeed(seed, projectName);
|
|
59
59
|
}
|
|
60
|
+
/** Pick the directory that seeds scope resolution from a host's candidate
|
|
61
|
+
* roots. OpenCode v1 hands the plugin both `worktree` (the repo root — but
|
|
62
|
+
* the filesystem root, e.g. `C:\` or `/`, when the opened folder is not a
|
|
63
|
+
* git repo) and `directory` (the folder actually opened). Seeding on a
|
|
64
|
+
* filesystem root collapses every non-git folder on a drive into one shared
|
|
65
|
+
* `project__workspace__…` scope (D71), so prefer `worktree` only when it is
|
|
66
|
+
* a real directory below the filesystem root. */
|
|
67
|
+
export function pickScopeRoot(worktree, directory) {
|
|
68
|
+
const isFsRoot = (p) => path.parse(p).root === p;
|
|
69
|
+
if (worktree && !isFsRoot(worktree))
|
|
70
|
+
return worktree;
|
|
71
|
+
if (directory)
|
|
72
|
+
return directory;
|
|
73
|
+
if (worktree)
|
|
74
|
+
return worktree;
|
|
75
|
+
return process.cwd();
|
|
76
|
+
}
|
|
60
77
|
export function resolveProjectScope(worktree) {
|
|
61
78
|
const target = worktree && worktree.length > 0 ? worktree : ".";
|
|
62
79
|
const remote = tryGitRemote(target);
|
package/dist/store/db.js
CHANGED
|
@@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
26
26
|
importance TEXT NOT NULL DEFAULT 'normal',
|
|
27
27
|
status TEXT NOT NULL DEFAULT 'active',
|
|
28
28
|
tags TEXT NOT NULL DEFAULT '',
|
|
29
|
+
aliases TEXT NOT NULL DEFAULT '',
|
|
29
30
|
content TEXT NOT NULL,
|
|
30
31
|
cjk TEXT NOT NULL DEFAULT '',
|
|
31
32
|
content_hash TEXT NOT NULL DEFAULT '',
|
|
@@ -52,6 +53,7 @@ const FTS_SCHEMA = `
|
|
|
52
53
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
53
54
|
content,
|
|
54
55
|
tags,
|
|
56
|
+
aliases,
|
|
55
57
|
type,
|
|
56
58
|
cjk,
|
|
57
59
|
scope_key UNINDEXED,
|
|
@@ -61,24 +63,24 @@ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
|
61
63
|
);
|
|
62
64
|
|
|
63
65
|
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
64
|
-
INSERT INTO memories_fts(rowid, content, tags, type, cjk, scope_key)
|
|
65
|
-
VALUES (new.rowid, new.content, new.tags, new.type, new.cjk, new.scope_key);
|
|
66
|
+
INSERT INTO memories_fts(rowid, content, tags, aliases, type, cjk, scope_key)
|
|
67
|
+
VALUES (new.rowid, new.content, new.tags, new.aliases, new.type, new.cjk, new.scope_key);
|
|
66
68
|
END;
|
|
67
69
|
|
|
68
70
|
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
69
|
-
INSERT INTO memories_fts(memories_fts, rowid, content, tags, type, cjk, scope_key)
|
|
70
|
-
VALUES ('delete', old.rowid, old.content, old.tags, old.type, old.cjk, old.scope_key);
|
|
71
|
+
INSERT INTO memories_fts(memories_fts, rowid, content, tags, aliases, type, cjk, scope_key)
|
|
72
|
+
VALUES ('delete', old.rowid, old.content, old.tags, old.aliases, old.type, old.cjk, old.scope_key);
|
|
71
73
|
END;
|
|
72
74
|
|
|
73
75
|
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
|
74
|
-
INSERT INTO memories_fts(memories_fts, rowid, content, tags, type, cjk, scope_key)
|
|
75
|
-
VALUES ('delete', old.rowid, old.content, old.tags, old.type, old.cjk, old.scope_key);
|
|
76
|
-
INSERT INTO memories_fts(rowid, content, tags, type, cjk, scope_key)
|
|
77
|
-
VALUES (new.rowid, new.content, new.tags, new.type, new.cjk, new.scope_key);
|
|
76
|
+
INSERT INTO memories_fts(memories_fts, rowid, content, tags, aliases, type, cjk, scope_key)
|
|
77
|
+
VALUES ('delete', old.rowid, old.content, old.tags, old.aliases, old.type, old.cjk, old.scope_key);
|
|
78
|
+
INSERT INTO memories_fts(rowid, content, tags, aliases, type, cjk, scope_key)
|
|
79
|
+
VALUES (new.rowid, new.content, new.tags, new.aliases, new.type, new.cjk, new.scope_key);
|
|
78
80
|
END;
|
|
79
81
|
`;
|
|
80
82
|
/** Current index schema version. Bump when TABLE_SCHEMA/FTS_SCHEMA change. */
|
|
81
|
-
const SCHEMA_VERSION =
|
|
83
|
+
const SCHEMA_VERSION = 7;
|
|
82
84
|
function userVersion(d) {
|
|
83
85
|
const row = d.prepare("PRAGMA user_version").get();
|
|
84
86
|
return row.user_version;
|
package/dist/store/lifecycle.js
CHANGED
|
@@ -5,7 +5,7 @@ import { db } from "./db.js";
|
|
|
5
5
|
import { paths, projectRoot, inRepoMemoriesDirPath, } from "../paths.js";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
7
|
import { cjkIndexText } from "../retrieve/cjk.js";
|
|
8
|
-
import { parse, serialize, ulid, msToRfc3339, } from "./markdown.js";
|
|
8
|
+
import { parse, serialize, atomicWriteTextSync, ulid, msToRfc3339, } from "./markdown.js";
|
|
9
9
|
/**
|
|
10
10
|
* Dedup + lifecycle (V2-DESIGN §3.3, §3.4).
|
|
11
11
|
*
|
|
@@ -111,7 +111,7 @@ export function findMemoryFile(id) {
|
|
|
111
111
|
return { fm: parsed.fm, body: parsed.body, filePath, mtimeMs: st.mtimeMs };
|
|
112
112
|
}
|
|
113
113
|
export function rewriteMemoryFile(mf) {
|
|
114
|
-
|
|
114
|
+
atomicWriteTextSync(mf.filePath, serialize(mf.fm, mf.body));
|
|
115
115
|
}
|
|
116
116
|
/**
|
|
117
117
|
* Chain integrity (§3.3): supersedes/superseded_by must be pairwise
|
|
@@ -171,13 +171,21 @@ export function supersede(oldId, input) {
|
|
|
171
171
|
id: ulid(),
|
|
172
172
|
type: input.type ?? oldMf.fm.type,
|
|
173
173
|
tags: input.tags ?? oldMf.fm.tags,
|
|
174
|
+
// D61: aliases carry forward unless the caller replaces them.
|
|
175
|
+
aliases: input.aliases ?? oldMf.fm.aliases,
|
|
174
176
|
source: input.source ?? oldMf.fm.source,
|
|
175
177
|
status: "active",
|
|
176
178
|
created_at: now,
|
|
177
179
|
updated_at: now,
|
|
178
180
|
supersedes: oldId,
|
|
179
181
|
superseded_by: null,
|
|
180
|
-
|
|
182
|
+
// D64: the replacement inherits the old memory's place in the review
|
|
183
|
+
// flow. A draft stays a draft; anything that had entered review
|
|
184
|
+
// (proposed/approved/published/rejected) re-enters at "proposed" so
|
|
185
|
+
// promote can advance it — resetting to "draft" would strand the new
|
|
186
|
+
// file in the repo, where submit cannot reach it (not the outbox)
|
|
187
|
+
// and promote refuses drafts.
|
|
188
|
+
review_state: (oldMf.fm.review_state ?? "draft") === "draft" ? "draft" : "proposed",
|
|
181
189
|
proposed_by: null,
|
|
182
190
|
approved_by: null,
|
|
183
191
|
derived_from: null,
|
|
@@ -185,7 +193,7 @@ export function supersede(oldId, input) {
|
|
|
185
193
|
};
|
|
186
194
|
const dir = path.dirname(oldMf.filePath);
|
|
187
195
|
const newPath = path.join(dir, `${newFm.id}.md`);
|
|
188
|
-
|
|
196
|
+
atomicWriteTextSync(newPath, serialize(newFm, input.body));
|
|
189
197
|
const st = fs.statSync(newPath);
|
|
190
198
|
const newMf = {
|
|
191
199
|
fm: newFm,
|
|
@@ -222,6 +230,14 @@ export function setStatus(id, status) {
|
|
|
222
230
|
if (mf.fm.status === "superseded") {
|
|
223
231
|
throw new Error(`memory ${id} is superseded (chain-managed); supersede it again instead of changing status directly`);
|
|
224
232
|
}
|
|
233
|
+
// D64: retraction is a one-way door through this path. A retracted
|
|
234
|
+
// memory was withdrawn on purpose (wrong, sensitive, superseded by
|
|
235
|
+
// policy); silently flipping it back to active would return it to
|
|
236
|
+
// recall (retrieval excludes retracted) with no review. Save a new
|
|
237
|
+
// memory if the content is valid again.
|
|
238
|
+
if (mf.fm.status === "retracted" && status === "active") {
|
|
239
|
+
throw new Error(`memory ${id} is retracted and cannot return to active; save the content as a new memory if it is valid again`);
|
|
240
|
+
}
|
|
225
241
|
mf.fm.status = status;
|
|
226
242
|
mf.fm.updated_at = msToRfc3339(Date.now());
|
|
227
243
|
rewriteMemoryFile(mf);
|
package/dist/store/markdown.js
CHANGED
|
@@ -155,6 +155,12 @@ export function normalizeFrontmatter(raw) {
|
|
|
155
155
|
tags: Array.isArray(raw.tags)
|
|
156
156
|
? raw.tags.filter((t) => typeof t === "string")
|
|
157
157
|
: [],
|
|
158
|
+
...(Array.isArray(raw.aliases)
|
|
159
|
+
? (() => {
|
|
160
|
+
const aliases = normalizeAliases(raw.aliases.filter((a) => typeof a === "string"));
|
|
161
|
+
return aliases.length > 0 ? { aliases } : {};
|
|
162
|
+
})()
|
|
163
|
+
: {}),
|
|
158
164
|
source: typeof raw.source === "string" ? raw.source : "",
|
|
159
165
|
created_at: msToRfc3339(timeToMs(raw.created_at)),
|
|
160
166
|
updated_at: msToRfc3339(timeToMs(raw.updated_at)),
|
|
@@ -170,11 +176,43 @@ export function normalizeFrontmatter(raw) {
|
|
|
170
176
|
review_history: asReviewHistory(raw.review_history),
|
|
171
177
|
};
|
|
172
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* D61: normalize capture-time aliases — trim, drop empties, dedupe
|
|
181
|
+
* (case-insensitive), cap at 4. Bad aliases are noise, never an error.
|
|
182
|
+
* Shared by the tool ops, the CLI, and frontmatter parsing.
|
|
183
|
+
*/
|
|
184
|
+
export function normalizeAliases(input) {
|
|
185
|
+
if (!input)
|
|
186
|
+
return [];
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const a of input) {
|
|
190
|
+
const t = a.trim();
|
|
191
|
+
if (!t)
|
|
192
|
+
continue;
|
|
193
|
+
const k = t.toLowerCase();
|
|
194
|
+
if (seen.has(k))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(k);
|
|
197
|
+
out.push(t);
|
|
198
|
+
if (out.length >= 4)
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
173
203
|
export function isTaxonomyType(t) {
|
|
174
204
|
return TAXONOMY.has(t);
|
|
175
205
|
}
|
|
176
206
|
export function serialize(fm, body) {
|
|
177
|
-
|
|
207
|
+
// D61: an empty aliases list is noise in the file — drop the key entirely
|
|
208
|
+
// rather than relying on the YAML dumper to skip an undefined value
|
|
209
|
+
// (parse only sets the field when non-empty; keep both ends consistent).
|
|
210
|
+
let out = fm;
|
|
211
|
+
if (fm.aliases && fm.aliases.length === 0) {
|
|
212
|
+
out = { ...fm };
|
|
213
|
+
delete out.aliases;
|
|
214
|
+
}
|
|
215
|
+
const yml = yaml.dump(out, { lineWidth: -1, quotingType: '"' });
|
|
178
216
|
return `---\n${yml}---\n\n${body.trimEnd()}\n`;
|
|
179
217
|
}
|
|
180
218
|
/** Raw frontmatter parse (no normalization) — for migration tooling. */
|
|
@@ -199,18 +237,26 @@ export function parse(raw) {
|
|
|
199
237
|
return null;
|
|
200
238
|
return { fm: normalizeFrontmatter(parsed.rawFm), body: parsed.body };
|
|
201
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Atomic text publish: write a sibling tmp file and rename over the
|
|
242
|
+
* target, so a concurrent reader (another process mid-sync) never sees a
|
|
243
|
+
* torn half-written file. Same-directory rename is atomic on one fs.
|
|
244
|
+
* Every memory-file write/rewrite must go through here (D57 for creates,
|
|
245
|
+
* D64 extended to all rewrites: supersede flips, review transitions,
|
|
246
|
+
* submit landings, status changes).
|
|
247
|
+
*/
|
|
248
|
+
export function atomicWriteTextSync(filePath, text) {
|
|
249
|
+
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
|
250
|
+
fs.writeFileSync(tmpPath, text, "utf8");
|
|
251
|
+
fs.renameSync(tmpPath, filePath);
|
|
252
|
+
}
|
|
202
253
|
export function writeMemoryFile(fm, body) {
|
|
203
254
|
// 2B/D26: project drafts live in appdata (the outbox) — the in-repo dir
|
|
204
255
|
// only ever holds submitted memories (proposed/approved/published).
|
|
205
256
|
// Personal stays in appdata and never leaves the machine.
|
|
206
257
|
const dir = memoriesDirFor(fm.scope_key);
|
|
207
258
|
const filePath = path.join(dir, `${fm.id}.md`);
|
|
208
|
-
|
|
209
|
-
// target, so a concurrent reader (another process mid-sync) never sees a
|
|
210
|
-
// torn half-written memory. Same-directory rename is atomic on one fs.
|
|
211
|
-
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
|
212
|
-
fs.writeFileSync(tmpPath, serialize(fm, body), "utf8");
|
|
213
|
-
fs.renameSync(tmpPath, filePath);
|
|
259
|
+
atomicWriteTextSync(filePath, serialize(fm, body));
|
|
214
260
|
const st = fs.statSync(filePath);
|
|
215
261
|
return { filePath, mtimeMs: st.mtimeMs };
|
|
216
262
|
}
|
package/dist/store/migrate.js
CHANGED
|
@@ -2,8 +2,9 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { memoriesDirFor, memoriesDirPath } from "../paths.js";
|
|
4
4
|
import { db } from "./db.js";
|
|
5
|
-
import { iterMemoryFiles, readMemoryFile, serialize, } from "./markdown.js";
|
|
5
|
+
import { iterMemoryFiles, readMemoryFile, serialize, timeToMs, atomicWriteTextSync, } from "./markdown.js";
|
|
6
6
|
import { syncScope } from "./sync.js";
|
|
7
|
+
import { PERSONAL_SCOPE } from "../scope.js";
|
|
7
8
|
/** True if this scope has at least one `.md` file on disk. Does not create
|
|
8
9
|
* the directory if it doesn't already exist. */
|
|
9
10
|
export function scopeHasFiles(scopeKey) {
|
|
@@ -60,7 +61,10 @@ export function migrateScope(fromKey, toKey, opts = {}) {
|
|
|
60
61
|
else if (strategy === "overwrite") {
|
|
61
62
|
log(`conflict: overwriting destination (--on-conflict=overwrite): ${dstPath}`);
|
|
62
63
|
}
|
|
63
|
-
else if (dst.fm.updated_at >= mf.fm.updated_at) {
|
|
64
|
+
else if (timeToMs(dst.fm.updated_at) >= timeToMs(mf.fm.updated_at)) {
|
|
65
|
+
// Compare parsed instants, not RFC-3339 strings: lexicographic
|
|
66
|
+
// order breaks around fractional seconds ("…:00Z" > "…:00.005Z"),
|
|
67
|
+
// letting the older file win.
|
|
64
68
|
action = "skip";
|
|
65
69
|
log(`conflict: kept destination (newer updated_at): ${dstPath}`);
|
|
66
70
|
}
|
|
@@ -72,14 +76,21 @@ export function migrateScope(fromKey, toKey, opts = {}) {
|
|
|
72
76
|
stats.skipped++;
|
|
73
77
|
continue;
|
|
74
78
|
}
|
|
79
|
+
// Re-key AND re-scope: scope/visibility must follow the destination
|
|
80
|
+
// (P0 review, 2026-10-03). Rewriting only scope_key produced files
|
|
81
|
+
// submit rejects ("scope is personal") or personal memories carrying
|
|
82
|
+
// visibility: internal into exports.
|
|
83
|
+
const toPersonal = toKey === PERSONAL_SCOPE.key;
|
|
75
84
|
const newFm = {
|
|
76
85
|
...mf.fm,
|
|
77
86
|
scope_key: toKey,
|
|
87
|
+
scope: toPersonal ? "personal" : "project",
|
|
88
|
+
visibility: toPersonal ? "private" : "internal",
|
|
78
89
|
project_name: opts.toProjectName ?? mf.fm.project_name,
|
|
79
90
|
};
|
|
80
91
|
const raw = serialize(newFm, mf.body);
|
|
81
92
|
if (!dryRun) {
|
|
82
|
-
|
|
93
|
+
atomicWriteTextSync(dstPath, raw);
|
|
83
94
|
// Only unlink source if it's a different path (paths differ because
|
|
84
95
|
// scope dir differs; belt-and-suspenders check).
|
|
85
96
|
if (path.resolve(srcPath) !== path.resolve(dstPath)) {
|
package/dist/store/sync.js
CHANGED
|
@@ -7,8 +7,8 @@ import { iterMemoryFiles, iterInRepoMemoryFiles, readMemoryFile, timeToMs, } fro
|
|
|
7
7
|
import { projectRoot, paths } from "../paths.js";
|
|
8
8
|
import { loadConfig } from "../config.js";
|
|
9
9
|
const UPSERT_SQL = `
|
|
10
|
-
INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at, review_state)
|
|
11
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
10
|
+
INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, aliases, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at, review_state)
|
|
11
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
12
12
|
ON CONFLICT(id) DO UPDATE SET
|
|
13
13
|
scope_key = excluded.scope_key,
|
|
14
14
|
scope = excluded.scope,
|
|
@@ -19,6 +19,7 @@ const UPSERT_SQL = `
|
|
|
19
19
|
importance = excluded.importance,
|
|
20
20
|
status = excluded.status,
|
|
21
21
|
tags = excluded.tags,
|
|
22
|
+
aliases = excluded.aliases,
|
|
22
23
|
content = excluded.content,
|
|
23
24
|
cjk = excluded.cjk,
|
|
24
25
|
content_hash = excluded.content_hash,
|
|
@@ -53,7 +54,8 @@ export function upsertFromFile(mf) {
|
|
|
53
54
|
function writeRow(mf, mtimeMs) {
|
|
54
55
|
const { fm, body, filePath } = mf;
|
|
55
56
|
const tags = (fm.tags ?? []).join(",");
|
|
56
|
-
|
|
57
|
+
const aliases = (fm.aliases ?? []).join(",");
|
|
58
|
+
db().prepare(UPSERT_SQL).run(fm.id, fm.scope_key, fm.scope, fm.visibility, fm.project_name, fm.type, fm.role, fm.importance, fm.status, tags, aliases, body, cjkIndexText(body + "\n" + tags + "\n" + aliases), contentHash(body), fm.superseded_by ?? null, fm.source ?? "", filePath, mtimeMs, timeToMs(fm.created_at), timeToMs(fm.updated_at), fm.review_state ?? "draft");
|
|
57
59
|
}
|
|
58
60
|
export function deleteFromIndex(id) {
|
|
59
61
|
db().prepare(`DELETE FROM memories WHERE id = ?`).run(id);
|
package/dist/store/v2migrate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { paths } from "../paths.js";
|
|
4
|
-
import { parseRawFrontmatter, normalizeFrontmatter, isTaxonomyType, serialize, } from "./markdown.js";
|
|
4
|
+
import { parseRawFrontmatter, normalizeFrontmatter, isTaxonomyType, serialize, atomicWriteTextSync, } from "./markdown.js";
|
|
5
5
|
/**
|
|
6
6
|
* v1 → v2 file migration (V2-DESIGN §19). Explicit only — run via
|
|
7
7
|
* `open-memex migrate --to-v2`. Converts frontmatter in place:
|
|
@@ -174,7 +174,7 @@ function planDir(dir, memoriesRoot, result) {
|
|
|
174
174
|
function applyPlans(result) {
|
|
175
175
|
for (const plan of result.plans) {
|
|
176
176
|
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
177
|
-
|
|
177
|
+
atomicWriteTextSync(plan.toPath, serialize(plan.fm, plan.body));
|
|
178
178
|
if (plan.toPath !== plan.fromPath)
|
|
179
179
|
fs.unlinkSync(plan.fromPath);
|
|
180
180
|
}
|