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
package/dist/submit.js
CHANGED
|
@@ -25,7 +25,7 @@ import { loadConfig } from "./config.js";
|
|
|
25
25
|
import { inRepoMemoriesDir, memoriesDirPath, projectRoot } from "./paths.js";
|
|
26
26
|
import { resolveProjectScope } from "./scope.js";
|
|
27
27
|
import { contentHash } from "./store/lifecycle.js";
|
|
28
|
-
import { msToRfc3339, parse as parseMemory, readMemoryFile, serialize, } from "./store/markdown.js";
|
|
28
|
+
import { msToRfc3339, parse as parseMemory, readMemoryFile, serialize, atomicWriteTextSync, } from "./store/markdown.js";
|
|
29
29
|
import { upsertFromFile, recordSync, readSyncState } from "./store/sync.js";
|
|
30
30
|
import { db } from "./store/db.js";
|
|
31
31
|
import { currentAuthor } from "./review.js";
|
|
@@ -127,7 +127,7 @@ export function outboxDraftCount(scopeKey) {
|
|
|
127
127
|
.get(scopeKey, outboxDir + path.sep);
|
|
128
128
|
return row?.n ?? 0;
|
|
129
129
|
}
|
|
130
|
-
export function formatSyncStatus(st) {
|
|
130
|
+
export function formatSyncStatus(st, opts = {}) {
|
|
131
131
|
const lines = [];
|
|
132
132
|
lines.push(`project: ${st.projectName} (${st.scopeKey})`);
|
|
133
133
|
// D31: when the index was last synced and what triggered it.
|
|
@@ -138,19 +138,27 @@ export function formatSyncStatus(st) {
|
|
|
138
138
|
else {
|
|
139
139
|
lines.push(`last sync: never`);
|
|
140
140
|
}
|
|
141
|
+
// Agent-facing renders cap each section so a big store can't flood a
|
|
142
|
+
// tool result; the cap line points at the CLI, which shows everything.
|
|
143
|
+
const CAP = opts.cap ?? 20;
|
|
144
|
+
const more = (n) => ` … and ${n} more (full list: open-memex sync-status)`;
|
|
141
145
|
lines.push(`outbox (appdata, pending sync): ${st.outbox.length}`);
|
|
142
|
-
for (const e of st.outbox) {
|
|
146
|
+
for (const e of st.outbox.slice(0, CAP)) {
|
|
143
147
|
lines.push(` ${e.id} [${e.reviewState}] ${e.title}`);
|
|
144
148
|
}
|
|
149
|
+
if (st.outbox.length > CAP)
|
|
150
|
+
lines.push(more(st.outbox.length - CAP));
|
|
145
151
|
const byState = new Map();
|
|
146
152
|
for (const e of st.inRepo)
|
|
147
153
|
byState.set(e.reviewState, (byState.get(e.reviewState) ?? 0) + 1);
|
|
148
154
|
const breakdown = [...byState.entries()].map(([s, n]) => `${n} ${s}`).join(", ") || "none";
|
|
149
155
|
lines.push(`repo .ai/open-memex: ${st.inRepo.length} (${breakdown})`);
|
|
150
|
-
for (const e of st.inRepo) {
|
|
156
|
+
for (const e of st.inRepo.slice(0, CAP)) {
|
|
151
157
|
const u = e.gitState === "uncommitted" ? " (uncommitted)" : "";
|
|
152
158
|
lines.push(` ${e.id} [${e.reviewState}]${u} ${e.title}`);
|
|
153
159
|
}
|
|
160
|
+
if (st.inRepo.length > CAP)
|
|
161
|
+
lines.push(more(st.inRepo.length - CAP));
|
|
154
162
|
if (st.uncommitted.length > 0) {
|
|
155
163
|
lines.push(`note: ${st.uncommitted.length} repo file(s) not yet committed — they ride with the working tree until you commit.`);
|
|
156
164
|
}
|
|
@@ -279,7 +287,7 @@ export function submitMemories(ids, opts = {}) {
|
|
|
279
287
|
],
|
|
280
288
|
};
|
|
281
289
|
const text = serialize(fm, parsed.body);
|
|
282
|
-
|
|
290
|
+
atomicWriteTextSync(destPath, text);
|
|
283
291
|
written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(parsed.body) });
|
|
284
292
|
submitted.push({ id: v.id, filePath: destPath, reviewState: destState });
|
|
285
293
|
}
|
package/dist/tools/memory.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PERSONAL_SCOPE } from "../scope.js";
|
|
2
|
+
import { syncScope } from "../store/sync.js";
|
|
1
3
|
import { addMemory, searchMemories, listMemories, supersedeMemory, forgetMemory, memoryAddArgs, memorySearchArgs, memoryListArgs, memorySupersedeArgs, memoryForgetArgs, TOOL_DESCRIPTIONS, withOutboxNote, } from "./ops.js";
|
|
2
4
|
// Local stand-in for the SDK's tool() helper — same signature, and the SDK
|
|
3
5
|
// function is a runtime identity (`return input`). Do NOT import it from
|
|
@@ -9,10 +11,32 @@ function tool(input) {
|
|
|
9
11
|
return input;
|
|
10
12
|
}
|
|
11
13
|
export function makeTools(getScope, cfg) {
|
|
14
|
+
// D64: the plugin host syncs only at bootstrap, so without this the
|
|
15
|
+
// index goes stale for the whole session — memories added through the
|
|
16
|
+
// CLI or another client stay invisible to the agent, and same-content
|
|
17
|
+
// duplicates accumulate. Mirror the MCP server: reconcile from disk
|
|
18
|
+
// before every tool call. syncScope is mtime-based, so the steady-state
|
|
19
|
+
// cost is one directory scan.
|
|
20
|
+
const syncFirst = () => {
|
|
21
|
+
try {
|
|
22
|
+
syncScope(getScope().key, "request");
|
|
23
|
+
syncScope(PERSONAL_SCOPE.key, "request");
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
// Deliberate asymmetry with the MCP adapter (which lets sync errors
|
|
27
|
+
// propagate as tool errors): a failed sync must not take the plugin
|
|
28
|
+
// tools down — the index may be stale for this call but the tool
|
|
29
|
+
// still answers. Loud at debug, silent otherwise.
|
|
30
|
+
if (cfg.logLevel === "debug") {
|
|
31
|
+
console.error("[open-memex] pre-tool sync failed:", err);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
12
35
|
const memory_add = tool({
|
|
13
36
|
description: TOOL_DESCRIPTIONS.memory_add,
|
|
14
37
|
args: memoryAddArgs,
|
|
15
38
|
async execute(args) {
|
|
39
|
+
syncFirst();
|
|
16
40
|
return withOutboxNote(getScope().key, addMemory(getScope, cfg, args));
|
|
17
41
|
},
|
|
18
42
|
});
|
|
@@ -20,6 +44,7 @@ export function makeTools(getScope, cfg) {
|
|
|
20
44
|
description: TOOL_DESCRIPTIONS.memory_search,
|
|
21
45
|
args: memorySearchArgs,
|
|
22
46
|
async execute(args) {
|
|
47
|
+
syncFirst();
|
|
23
48
|
return searchMemories(getScope, args);
|
|
24
49
|
},
|
|
25
50
|
});
|
|
@@ -27,6 +52,7 @@ export function makeTools(getScope, cfg) {
|
|
|
27
52
|
description: TOOL_DESCRIPTIONS.memory_list,
|
|
28
53
|
args: memoryListArgs,
|
|
29
54
|
async execute(args) {
|
|
55
|
+
syncFirst();
|
|
30
56
|
return listMemories(getScope, args);
|
|
31
57
|
},
|
|
32
58
|
});
|
|
@@ -34,6 +60,7 @@ export function makeTools(getScope, cfg) {
|
|
|
34
60
|
description: TOOL_DESCRIPTIONS.memory_supersede,
|
|
35
61
|
args: memorySupersedeArgs,
|
|
36
62
|
async execute(args) {
|
|
63
|
+
syncFirst();
|
|
37
64
|
return withOutboxNote(getScope().key, supersedeMemory(cfg, args));
|
|
38
65
|
},
|
|
39
66
|
});
|
|
@@ -41,6 +68,7 @@ export function makeTools(getScope, cfg) {
|
|
|
41
68
|
description: TOOL_DESCRIPTIONS.memory_forget,
|
|
42
69
|
args: memoryForgetArgs,
|
|
43
70
|
async execute(args) {
|
|
71
|
+
syncFirst();
|
|
44
72
|
return withOutboxNote(getScope().key, forgetMemory(args));
|
|
45
73
|
},
|
|
46
74
|
});
|
package/dist/tools/ops.js
CHANGED
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { PERSONAL_SCOPE } from "../scope.js";
|
|
12
|
-
import { search, list, hitStateLabel } from "../retrieve/search.js";
|
|
13
|
-
import {
|
|
12
|
+
import { search, list, countList, hitStateLabel } from "../retrieve/search.js";
|
|
13
|
+
import { formatInventoryLine, truncationNote } from "../retrieve/display.js";
|
|
14
|
+
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, normalizeAliases, } from "../store/markdown.js";
|
|
14
15
|
import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
|
|
15
|
-
import { findDuplicates, supersede } from "../store/lifecycle.js";
|
|
16
|
+
import { findDuplicates, supersede, setStatus } from "../store/lifecycle.js";
|
|
16
17
|
import { db } from "../store/db.js";
|
|
17
18
|
import { redact } from "../redact.js";
|
|
18
19
|
import { getSyncStatus, formatSyncStatus, submitMemories, outboxDraftCount } from "../submit.js";
|
|
19
20
|
import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.js";
|
|
21
|
+
import { isInRepoMemoryFile } from "../paths.js";
|
|
20
22
|
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory, } from "../review.js";
|
|
21
23
|
/**
|
|
22
24
|
* D53: push, don't poll. Append the project-outbox pending count to mutating
|
|
@@ -35,11 +37,11 @@ export async function withOutboxNote(scopeKey, p, reviewHint) {
|
|
|
35
37
|
}
|
|
36
38
|
/** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
|
|
37
39
|
export const TOOL_DESCRIPTIONS = {
|
|
38
|
-
memory_add: "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Do not wait to be asked. Keep each memory to one self-contained statement. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
|
|
39
|
-
memory_search: "Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or personal scope. Call before asking the user about past decisions, conventions, or preferences they may have told you before
|
|
40
|
-
memory_list: "List memories in a scope, newest first.
|
|
40
|
+
memory_add: "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Worth saving: decisions and their reasons, preferences, conventions, gotchas, approaches tried and abandoned. Not worth saving: one-off task details or anything re-derivable from the code. Do not wait to be asked. For facts you infer yourself rather than the user stating, propose them first and save only on approval. Keep each memory to one self-contained statement; attach aliases when the parameter is available. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
|
|
41
|
+
memory_search: "Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or personal scope. Call before asking the user about past decisions, conventions, or preferences they may have told you before. Search well: break the question into its concepts and try 2–3 phrasings per concept — synonyms, the user's other language, shorter keyword forms — and check the other scope too, before concluding nothing is stored.",
|
|
42
|
+
memory_list: "List memories in a scope, newest first, as a numbered inventory. Each line keeps the raw fields — [type] id=… created=… source=… — say them back to the user in plain words (source=user → 'you told me this'; inference → 'I inferred this, check me'; keyword → 'caught from your own wording'), never read the raw id aloud unless they ask. When the user asks 'what do you remember about me?', use scope=both and present the inventory conversationally. The user may point at an entry by its number ('delete #3'): numbers are only valid for the listing you just produced — re-run memory_list, read the candidate back in full, and get a confirmation before calling memory_forget (deletion is permanent). include=all is the audit view (superseded versions, retracted, archived also shown).",
|
|
41
43
|
memory_supersede: "Replace an existing memory with a newer version. The old memory is kept as history (status: superseded) and retrieval returns the new one. Use when a saved fact becomes outdated and should be replaced rather than duplicated.",
|
|
42
|
-
memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
|
|
44
|
+
memory_forget: "Delete a memory by id. Use when the user asks to forget something. Deletion is permanent: before deleting, restate the memory's content to the user and get a confirmation. With soft=true the memory is hidden instead (retracted: out of lists and search, file kept, one-way).",
|
|
43
45
|
memory_status: "Show the project memory sync pipeline: drafts waiting in the outbox (appdata), memories in the repo awaiting review or published, and any repo files not yet committed. Call this at session start, when the server reports drafts waiting for review, or when the user says 'sync memory' (or '同步记忆'); then ask the user which drafts to sync. (MCP server and the `open-memex sync-status` CLI; the opencode native plugin does not expose this tool.)",
|
|
44
46
|
memory_submit: "Move outbox drafts into the repo memory dir for review: copies the drafts in as proposed (or keeps a local approval), commits locally on the current branch, and moves the outbox originals out. Never creates a branch on its own — pass branch= only with the user's explicit approval for the full chain. Prints the push and PR commands — those need the user's explicit approval and are never run automatically. (MCP server and the `open-memex submit` CLI; the opencode native plugin does not expose this tool.)",
|
|
45
47
|
memory_propose: "Copy personal memories into the project outbox as review drafts. The personal originals stay put. (MCP server and the `open-memex propose` CLI; the opencode native plugin does not expose this tool.)",
|
|
@@ -52,6 +54,10 @@ export const scopeArg = z
|
|
|
52
54
|
.enum(["project", "personal", "user"])
|
|
53
55
|
.optional()
|
|
54
56
|
.describe("Memory scope. `project` = tied to this repo. `personal` = global across all your projects. `user` is a deprecated alias of `personal`. Default: project.");
|
|
57
|
+
export const aliasesArg = z
|
|
58
|
+
.array(z.string())
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("Optional: alternate phrasings of this fact — synonyms, another way a question might be worded, equivalents in the user's other language (e.g. 节假日 for 'public holidays'). They are indexed with the memory so differently-worded questions still match. Only used when the install has capture aliases enabled (init default). Send at most 4; extras and blanks are dropped silently, never an error.");
|
|
55
61
|
export const memoryAddArgs = {
|
|
56
62
|
content: z.string().min(1).describe("The fact to remember. One idea per memory."),
|
|
57
63
|
type: z
|
|
@@ -60,6 +66,7 @@ export const memoryAddArgs = {
|
|
|
60
66
|
.describe("Category of memory. Default: fact."),
|
|
61
67
|
scope: scopeArg,
|
|
62
68
|
tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
|
|
69
|
+
aliases: aliasesArg,
|
|
63
70
|
source: z
|
|
64
71
|
.string()
|
|
65
72
|
.optional()
|
|
@@ -78,9 +85,16 @@ export const memorySearchArgs = {
|
|
|
78
85
|
limit: z.number().int().min(1).max(50).optional(),
|
|
79
86
|
};
|
|
80
87
|
export const memoryListArgs = {
|
|
81
|
-
scope:
|
|
88
|
+
scope: z
|
|
89
|
+
.enum(["project", "personal", "user", "both"])
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("Which scope(s) to list. `project` = tied to this repo. `personal` = global across all your projects. `both` lists each scope's newest. Default: project."),
|
|
82
92
|
type: z.string().optional(),
|
|
83
93
|
limit: z.number().int().min(1).max(100).optional(),
|
|
94
|
+
include: z
|
|
95
|
+
.enum(["active", "all"])
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("D68: `active` (default) shows what is currently remembered — newest version of each fact, retracted/archived hidden. `all` is the audit view: every stored row including superseded versions and hidden ones. The default CHANGED in D68: superseded versions no longer appear unless include=all."),
|
|
84
98
|
};
|
|
85
99
|
export const memorySupersedeArgs = {
|
|
86
100
|
id: z.string().min(1).describe("ID of the memory being replaced."),
|
|
@@ -90,9 +104,14 @@ export const memorySupersedeArgs = {
|
|
|
90
104
|
.optional()
|
|
91
105
|
.describe("Category of the new memory. Defaults to the old memory's type."),
|
|
92
106
|
tags: z.array(z.string()).optional().describe("Optional tags for the new memory."),
|
|
107
|
+
aliases: aliasesArg,
|
|
93
108
|
};
|
|
94
109
|
export const memoryForgetArgs = {
|
|
95
110
|
id: z.string().min(1),
|
|
111
|
+
soft: z
|
|
112
|
+
.boolean()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("D68: hide instead of delete. The memory is retracted — excluded from lists and search — but its file stays. Retraction is one-way (D64): it does not come back; to restore the fact, save it again as a new memory. Offer `soft` when the user is unsure about deleting."),
|
|
96
115
|
};
|
|
97
116
|
export const memoryStatusArgs = {};
|
|
98
117
|
export const memorySubmitArgs = {
|
|
@@ -149,6 +168,7 @@ function buildFrontmatter(s, body) {
|
|
|
149
168
|
importance: "normal",
|
|
150
169
|
status: "active",
|
|
151
170
|
tags: body.tags,
|
|
171
|
+
...(body.aliases && body.aliases.length > 0 ? { aliases: body.aliases } : {}),
|
|
152
172
|
source: body.source,
|
|
153
173
|
created_at: rfc,
|
|
154
174
|
updated_at: rfc,
|
|
@@ -180,6 +200,13 @@ export async function addMemory(getScope, cfg, args) {
|
|
|
180
200
|
const fm = buildFrontmatter(s, {
|
|
181
201
|
type: args.type ?? "fact",
|
|
182
202
|
tags: args.tags ?? [],
|
|
203
|
+
// D61: aliases are stored only when the install enables them (init
|
|
204
|
+
// asks once, default on); normalize caps at 4 and drops junk silently.
|
|
205
|
+
// Aliases pass through the same redaction as content (write-path
|
|
206
|
+
// invariant) — an alias carrying a token gets masked, not stored raw.
|
|
207
|
+
aliases: cfg.captureAliases
|
|
208
|
+
? normalizeAliases((args.aliases ?? []).map((a) => redact(a, cfg.redactPatterns).content))
|
|
209
|
+
: [],
|
|
183
210
|
source: args.source ?? "tool",
|
|
184
211
|
});
|
|
185
212
|
const { filePath } = writeMemoryFile(fm, redacted);
|
|
@@ -214,13 +241,52 @@ export async function searchMemories(getScope, args) {
|
|
|
214
241
|
return { title: `memory: ${hits.length} result(s)`, output: lines.join("\n") };
|
|
215
242
|
}
|
|
216
243
|
export async function listMemories(getScope, args) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
244
|
+
// D68: the list is the user's inventory of what is remembered. Lines
|
|
245
|
+
// are structured (number + id + created= + source= + state tags) and
|
|
246
|
+
// every section reports its true total — a truncated list says so
|
|
247
|
+
// instead of quietly looking complete (the D66 sync-status lesson).
|
|
248
|
+
const include = args.include ?? "active";
|
|
249
|
+
// D70: numbering runs across every section of THIS listing, so "delete
|
|
250
|
+
// #3" names exactly one memory. A per-section counter emitted two "#1"s.
|
|
251
|
+
let seq = 0;
|
|
252
|
+
const render = (scopeKey) => {
|
|
253
|
+
const hits = list(scopeKey, { type: args.type, limit: args.limit, include });
|
|
254
|
+
const total = countList(scopeKey, { type: args.type, include });
|
|
255
|
+
const lines = hits.map((h) => formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
|
|
256
|
+
const note = truncationNote(hits.length, total);
|
|
257
|
+
if (note)
|
|
258
|
+
lines.push(note);
|
|
259
|
+
return lines;
|
|
260
|
+
};
|
|
261
|
+
if (args.scope === "both") {
|
|
262
|
+
// D64: "what do you remember about me" spans both stores; search
|
|
263
|
+
// already defaults to both, list now matches. Each scope lists its
|
|
264
|
+
// own newest (the limit applies per scope).
|
|
265
|
+
const project = getScope();
|
|
266
|
+
const sections = [];
|
|
267
|
+
for (const s of [PERSONAL_SCOPE, project]) {
|
|
268
|
+
const lines = render(s.key);
|
|
269
|
+
const heading = s.key === PERSONAL_SCOPE.key
|
|
270
|
+
? "## About you (personal — stays on this machine, applies everywhere)"
|
|
271
|
+
: `## This project (${s.projectName || s.key})`;
|
|
272
|
+
sections.push(lines.length === 0 ? `${heading}\n(none yet)` : `${heading}\n${lines.join("\n")}`);
|
|
273
|
+
}
|
|
274
|
+
return { title: "memory: list (both scopes)", output: sections.join("\n\n") };
|
|
275
|
+
}
|
|
276
|
+
const s = resolveScope(getScope, args.scope === "project" || args.scope === "personal" || args.scope === "user"
|
|
277
|
+
? args.scope
|
|
278
|
+
: undefined);
|
|
279
|
+
const lines = render(s.key);
|
|
280
|
+
if (lines.length === 0) {
|
|
220
281
|
return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
|
|
221
282
|
}
|
|
222
|
-
const
|
|
223
|
-
|
|
283
|
+
const heading = s.key === PERSONAL_SCOPE.key
|
|
284
|
+
? "## About you (personal — stays on this machine, applies everywhere)"
|
|
285
|
+
: `## This project (${s.projectName || s.key})`;
|
|
286
|
+
return {
|
|
287
|
+
title: `memory: ${countList(s.key, { type: args.type, include })} in ${s.key}`,
|
|
288
|
+
output: `${heading}\n${lines.join("\n")}`,
|
|
289
|
+
};
|
|
224
290
|
}
|
|
225
291
|
export async function supersedeMemory(cfg, args) {
|
|
226
292
|
const { content: redacted, hadSecret, matchedPattern } = redact(args.content, cfg.redactPatterns);
|
|
@@ -230,6 +296,11 @@ export async function supersedeMemory(cfg, args) {
|
|
|
230
296
|
body: redacted,
|
|
231
297
|
type: args.type,
|
|
232
298
|
tags: args.tags,
|
|
299
|
+
// D61: explicit aliases replace the carried-over ones, only when
|
|
300
|
+
// the install enables capture aliases. Same redaction as content.
|
|
301
|
+
aliases: cfg.captureAliases && args.aliases !== undefined
|
|
302
|
+
? normalizeAliases(args.aliases.map((a) => redact(a, cfg.redactPatterns).content))
|
|
303
|
+
: undefined,
|
|
233
304
|
source: "tool",
|
|
234
305
|
});
|
|
235
306
|
upsertFromFile(oldMf);
|
|
@@ -253,17 +324,57 @@ export async function forgetMemory(args) {
|
|
|
253
324
|
if (!row) {
|
|
254
325
|
return { title: "memory: not found", output: `No memory with id ${args.id}.` };
|
|
255
326
|
}
|
|
327
|
+
if (args.soft) {
|
|
328
|
+
// D68: hide instead of delete. The status flip is the whole action —
|
|
329
|
+
// retrieval and the inventory exclude retracted, the file stays put.
|
|
330
|
+
// One-way (D64): no un-hide; restoring the fact means saving it again.
|
|
331
|
+
try {
|
|
332
|
+
const mf = setStatus(args.id, "retracted");
|
|
333
|
+
upsertFromFile(mf);
|
|
334
|
+
return {
|
|
335
|
+
title: "memory: hidden",
|
|
336
|
+
output: `Hidden ${args.id} (retracted). It no longer appears in lists or search, but the file is kept. This is one-way — to bring the fact back, save it again as a new memory.` +
|
|
337
|
+
// D70: a retraction of an in-repo memory is a local working-tree
|
|
338
|
+
// edit like the delete — without a commit (and a push) the memory
|
|
339
|
+
// stands on every teammate's clone, so "hidden" would be a lie.
|
|
340
|
+
(isInRepoMemoryFile(row.file_path)
|
|
341
|
+
? ` Note: this memory lives in the repo (.ai/open-memex/), so the retraction is only local until you commit and push it — until then the team still sees it.`
|
|
342
|
+
: ""),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
return { title: "memory: hide failed", output: e.message };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
256
349
|
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
350
|
+
// Only ENOENT counts as "deleted": on any other unlink failure the file
|
|
351
|
+
// is still the source of truth and would be re-indexed on next sync, so
|
|
352
|
+
// report failure instead of lying (P0 review, 2026-10-03).
|
|
257
353
|
if (row.file_path) {
|
|
258
354
|
try {
|
|
259
355
|
fs.unlinkSync(row.file_path);
|
|
260
356
|
}
|
|
261
|
-
catch {
|
|
262
|
-
|
|
357
|
+
catch (e) {
|
|
358
|
+
const code = e.code;
|
|
359
|
+
if (code !== "ENOENT") {
|
|
360
|
+
return {
|
|
361
|
+
title: "memory: forget failed",
|
|
362
|
+
output: `Could not delete ${row.file_path} (${code ?? "error"}) — the memory was NOT forgotten and is still in the index. Close anything holding the file and retry.`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
263
365
|
}
|
|
264
366
|
}
|
|
265
367
|
deleteFromIndex(args.id);
|
|
266
|
-
|
|
368
|
+
// D64: forgetting an in-repo memory deletes a file inside the user's
|
|
369
|
+
// git working tree. Say so — the deletion should ride a commit, or the
|
|
370
|
+
// file silently diverges from what teammates have.
|
|
371
|
+
const inRepo = isInRepoMemoryFile(row.file_path);
|
|
372
|
+
return {
|
|
373
|
+
title: "memory: forgotten",
|
|
374
|
+
output: inRepo
|
|
375
|
+
? `Deleted ${args.id}. Note: the file lived in the repo (.ai/open-memex/) — commit the deletion so the team sees it too.`
|
|
376
|
+
: `Deleted ${args.id}.`,
|
|
377
|
+
};
|
|
267
378
|
}
|
|
268
379
|
// ---------------------------------------------------------------------------
|
|
269
380
|
// 2B/D26: review workflow ops (also exposed over MCP so agents can drive the
|
package/docs/CURATOR.md
CHANGED
|
@@ -7,9 +7,13 @@ decide who is *allowed* to.
|
|
|
7
7
|
|
|
8
8
|
## What the curator does
|
|
9
9
|
|
|
10
|
-
1. **Triage proposals.**
|
|
11
|
-
|
|
12
|
-
`open-memex
|
|
10
|
+
1. **Triage proposals.** Contributors put a memory up for review in two
|
|
11
|
+
steps: `open-memex propose` (personal memory → outbox draft), then
|
|
12
|
+
`open-memex submit` (draft lands in the repo as `proposed`, riding a
|
|
13
|
+
branch/PR). The curator reviews the proposed ones: `open-memex promote
|
|
14
|
+
<id>` approves (promote again to publish), or `open-memex promote <id>
|
|
15
|
+
--reject` to send back, with `--note` saying why. (`promote` refuses
|
|
16
|
+
drafts by design — an outbox draft must be submitted first.)
|
|
13
17
|
2. **Resolve conflicts.** `open-memex resolve` lists file-level and semantic
|
|
14
18
|
conflicts. The curator merges or picks a winner — Core never silently
|
|
15
19
|
resolves a semantic conflict; both sides stay `active` until a human
|