open-memex 0.6.2 → 0.7.0-alpha.11
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 +25 -7
- package/CHANGELOG.md +103 -1
- package/README.md +54 -16
- package/README.zh-CN.md +30 -15
- 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 +180 -79
- package/dist/export.js +12 -2
- package/dist/index.js +20 -112
- package/dist/init.js +103 -11
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +176 -0
- package/dist/mcp.js +21 -5
- package/dist/opencode-v2.js +43 -0
- package/dist/paths.js +34 -2
- package/dist/plugin-core.js +138 -0
- 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/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 +335 -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 +237 -3
- package/scripts/test-full.ts +362 -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 +181 -71
- package/src/export.ts +14 -2
- package/src/index.ts +25 -112
- package/src/init.ts +108 -11
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +242 -0
- package/src/mcp.ts +21 -5
- package/src/opencode-v2.ts +119 -0
- package/src/paths.ts +35 -1
- package/src/plugin-core.ts +152 -0
- 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/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,138 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "./scope.js";
|
|
3
|
+
import { db } from "./store/db.js";
|
|
4
|
+
import { syncScope, upsertFromFile } from "./store/sync.js";
|
|
5
|
+
import { scopeHasFiles } from "./store/migrate.js";
|
|
6
|
+
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
|
|
7
|
+
import { buildContextBlock } from "./retrieve/inject.js";
|
|
8
|
+
import { detectKeywords } from "./capture/keywords.js";
|
|
9
|
+
import { findDuplicates } from "./store/lifecycle.js";
|
|
10
|
+
import { redact } from "./redact.js";
|
|
11
|
+
/**
|
|
12
|
+
* Host-agnostic plugin core (D60). Both opencode host generations adapt to
|
|
13
|
+
* this module: the v1 plugin (src/index.ts `server`) and the v2 plugin
|
|
14
|
+
* (src/opencode-v2.ts `setup`). Host-specific glue — tool registration and
|
|
15
|
+
* hook shapes — stays in the adapters; everything memory-shaped lives here.
|
|
16
|
+
*/
|
|
17
|
+
export const PLUGIN_ID = "open-memex";
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the project scope for a host working directory and bring the
|
|
20
|
+
* index up to date. Never throws: a broken store must not keep the host
|
|
21
|
+
* from starting — failures land in the host's log instead.
|
|
22
|
+
*/
|
|
23
|
+
export function bootstrapPlugin(root) {
|
|
24
|
+
const cfg = loadConfig();
|
|
25
|
+
const scope = resolveProjectScope(root);
|
|
26
|
+
try {
|
|
27
|
+
db();
|
|
28
|
+
syncScope(scope.key);
|
|
29
|
+
syncScope(PERSONAL_SCOPE.key);
|
|
30
|
+
if (cfg.logLevel === "debug") {
|
|
31
|
+
console.log(`[open-memex] loaded. scope=${scope.key}`);
|
|
32
|
+
}
|
|
33
|
+
// If a git remote was added *after* memories were first captured, the
|
|
34
|
+
// scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
|
|
35
|
+
// disk and tell the user how to migrate. We do NOT auto-migrate: two
|
|
36
|
+
// different repos at the same cwd would collide.
|
|
37
|
+
const cwdScope = resolveCwdScope(root);
|
|
38
|
+
if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
|
|
39
|
+
console.warn(`[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
|
|
40
|
+
`Current project scope is "${scope.key}". ` +
|
|
41
|
+
`To move them: npm run cli -- migrate --from ${cwdScope.key}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error("[open-memex] init failed:", err);
|
|
46
|
+
}
|
|
47
|
+
return { cfg, scope, injectedSessions: new Set() };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Keyword capture (§ capture): scan one user message for "remember …"
|
|
51
|
+
* triggers and save each hit. Shared by the v1 `chat.message` hook and
|
|
52
|
+
* the v2 session `prompt` hook — both hand us the message text.
|
|
53
|
+
*/
|
|
54
|
+
export function captureFromText(text, state) {
|
|
55
|
+
const { cfg, scope } = state;
|
|
56
|
+
if (!cfg.keywordCaptureEnabled || !text)
|
|
57
|
+
return;
|
|
58
|
+
const hits = detectKeywords(text, cfg);
|
|
59
|
+
for (const h of hits) {
|
|
60
|
+
const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
|
|
61
|
+
// Secrets are masked (first 4 chars kept) and the capture proceeds;
|
|
62
|
+
// skip only when nothing usable remains.
|
|
63
|
+
if (content.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
if (hadSecret && cfg.logLevel === "debug") {
|
|
66
|
+
console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
|
|
67
|
+
}
|
|
68
|
+
// Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
|
|
69
|
+
const target = h.personal ? PERSONAL_SCOPE : scope;
|
|
70
|
+
// Dedup (§3.4): skip exact duplicates captured before.
|
|
71
|
+
if (findDuplicates(target.key, content).exact)
|
|
72
|
+
continue;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const rfc = msToRfc3339(now);
|
|
75
|
+
const fm = {
|
|
76
|
+
id: ulid(),
|
|
77
|
+
schema_version: 2,
|
|
78
|
+
scope_key: target.key,
|
|
79
|
+
scope: target.kind === "project" ? "project" : "personal",
|
|
80
|
+
visibility: target.kind === "project" ? "internal" : "private",
|
|
81
|
+
project_name: target.projectName,
|
|
82
|
+
type: "fact",
|
|
83
|
+
role: "knowledge",
|
|
84
|
+
importance: "normal",
|
|
85
|
+
status: "active",
|
|
86
|
+
tags: ["keyword"],
|
|
87
|
+
source: "keyword",
|
|
88
|
+
created_at: rfc,
|
|
89
|
+
updated_at: rfc,
|
|
90
|
+
supersedes: null,
|
|
91
|
+
superseded_by: null,
|
|
92
|
+
review_state: "draft",
|
|
93
|
+
proposed_by: null,
|
|
94
|
+
approved_by: null,
|
|
95
|
+
derived_from: null,
|
|
96
|
+
review_note: null,
|
|
97
|
+
review_history: [],
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
const { filePath } = writeMemoryFile(fm, content);
|
|
101
|
+
const mf = readMemoryFile(filePath);
|
|
102
|
+
if (mf)
|
|
103
|
+
upsertFromFile(mf);
|
|
104
|
+
// Capture feedback: always visible (not debug-only) — the user said
|
|
105
|
+
// "记住…", they should see that it landed. The opencode plugin API
|
|
106
|
+
// offers no toast channel, so the plugin log is the feedback surface.
|
|
107
|
+
const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
|
|
108
|
+
console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.error("[open-memex] keyword capture failed:", err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* First-turn context injection: exactly once per session. The host adapter
|
|
117
|
+
* supplies `push` (v1: output.system.push; v2: the session context hook's
|
|
118
|
+
* system array). Returns true when a block was pushed.
|
|
119
|
+
*/
|
|
120
|
+
export function injectOnce(sessionID, state, push) {
|
|
121
|
+
const { cfg, scope } = state;
|
|
122
|
+
if (!cfg.injectOnFirstTurn)
|
|
123
|
+
return false;
|
|
124
|
+
if (state.injectedSessions.has(sessionID))
|
|
125
|
+
return false;
|
|
126
|
+
state.injectedSessions.add(sessionID);
|
|
127
|
+
try {
|
|
128
|
+
const block = buildContextBlock(scope, cfg);
|
|
129
|
+
if (block) {
|
|
130
|
+
push(block);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error("[open-memex] context injection failed:", err);
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
@@ -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/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
|
}
|