open-memex 0.3.0-alpha → 0.3.0-alpha.3
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 +12 -6
- package/README.md +16 -7
- package/README.zh-CN.md +15 -6
- package/dist/capture/keywords.js +43 -0
- package/dist/cli.js +531 -0
- package/dist/config.js +123 -0
- package/dist/doctor.js +151 -0
- package/dist/index.js +121 -0
- package/dist/init.js +323 -0
- package/dist/mcp.js +85 -0
- package/dist/paths.js +36 -0
- package/dist/redact.js +249 -0
- package/dist/retrieve/cjk.js +58 -0
- package/dist/retrieve/inject.js +25 -0
- package/dist/retrieve/search.js +147 -0
- package/dist/scope.js +70 -0
- package/dist/store/db.js +132 -0
- package/dist/store/lifecycle.js +214 -0
- package/dist/store/markdown.js +197 -0
- package/dist/store/migrate.js +109 -0
- package/dist/store/sync.js +88 -0
- package/dist/store/v2migrate.js +158 -0
- package/dist/tools/memory.js +40 -0
- package/dist/tools/ops.js +186 -0
- package/docs/V2-DESIGN.md +19 -0
- package/package.json +5 -3
- package/src/cli.ts +37 -21
- package/src/doctor.ts +16 -5
- package/src/init.ts +55 -6
- package/tsconfig.build.json +1 -0
- package/bin/open-memex.js +0 -28
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { cjkIndexText } from "../retrieve/cjk.js";
|
|
4
|
+
import { contentHash, repairChain } from "./lifecycle.js";
|
|
5
|
+
import { iterMemoryFiles, readMemoryFile, timeToMs, } from "./markdown.js";
|
|
6
|
+
const UPSERT_SQL = `
|
|
7
|
+
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)
|
|
8
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
9
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
10
|
+
scope_key = excluded.scope_key,
|
|
11
|
+
scope = excluded.scope,
|
|
12
|
+
visibility = excluded.visibility,
|
|
13
|
+
project_name = excluded.project_name,
|
|
14
|
+
type = excluded.type,
|
|
15
|
+
role = excluded.role,
|
|
16
|
+
importance = excluded.importance,
|
|
17
|
+
status = excluded.status,
|
|
18
|
+
tags = excluded.tags,
|
|
19
|
+
content = excluded.content,
|
|
20
|
+
cjk = excluded.cjk,
|
|
21
|
+
content_hash = excluded.content_hash,
|
|
22
|
+
superseded_by = excluded.superseded_by,
|
|
23
|
+
source = excluded.source,
|
|
24
|
+
file_path = excluded.file_path,
|
|
25
|
+
mtime_ms = excluded.mtime_ms,
|
|
26
|
+
updated_at = excluded.updated_at
|
|
27
|
+
`;
|
|
28
|
+
export function upsertFromFile(mf) {
|
|
29
|
+
// Chain integrity (§3.3): every write path through Core validates the
|
|
30
|
+
// supersedes/superseded_by pair and auto-completes a missing side.
|
|
31
|
+
const { warnings, repaired } = repairChain(mf);
|
|
32
|
+
for (const w of warnings)
|
|
33
|
+
console.warn(`[open-memex] chain: ${w}`);
|
|
34
|
+
const mtimeOf = (m) => {
|
|
35
|
+
try {
|
|
36
|
+
return fs.statSync(m.filePath).mtimeMs;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return m.mtimeMs; // file vanished mid-repair; keep the old mtime
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
writeRow(mf, repaired.some((r) => r.filePath === mf.filePath) ? mtimeOf(mf) : mf.mtimeMs);
|
|
43
|
+
for (const r of repaired) {
|
|
44
|
+
if (r.filePath === mf.filePath)
|
|
45
|
+
continue;
|
|
46
|
+
writeRow(r, mtimeOf(r));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function writeRow(mf, mtimeMs) {
|
|
50
|
+
const { fm, body, filePath } = mf;
|
|
51
|
+
const tags = (fm.tags ?? []).join(",");
|
|
52
|
+
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, body, cjkIndexText(body + "\n" + tags), contentHash(body), fm.superseded_by ?? null, fm.source ?? "", filePath, mtimeMs, timeToMs(fm.created_at), timeToMs(fm.updated_at));
|
|
53
|
+
}
|
|
54
|
+
export function deleteFromIndex(id) {
|
|
55
|
+
db().prepare(`DELETE FROM memories WHERE id = ?`).run(id);
|
|
56
|
+
}
|
|
57
|
+
export function syncScope(scopeKey) {
|
|
58
|
+
const d = db();
|
|
59
|
+
const stats = { added: 0, updated: 0, removed: 0, scanned: 0 };
|
|
60
|
+
const existing = d
|
|
61
|
+
.prepare(`SELECT id, file_path, mtime_ms FROM memories WHERE scope_key = ?`)
|
|
62
|
+
.all(scopeKey);
|
|
63
|
+
const existingById = new Map(existing.map((r) => [r.id, r]));
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for (const fp of iterMemoryFiles(scopeKey)) {
|
|
66
|
+
const mf = readMemoryFile(fp);
|
|
67
|
+
if (!mf)
|
|
68
|
+
continue;
|
|
69
|
+
stats.scanned++;
|
|
70
|
+
seen.add(mf.fm.id);
|
|
71
|
+
const prev = existingById.get(mf.fm.id);
|
|
72
|
+
if (!prev) {
|
|
73
|
+
upsertFromFile(mf);
|
|
74
|
+
stats.added++;
|
|
75
|
+
}
|
|
76
|
+
else if (prev.mtime_ms !== mf.mtimeMs || prev.file_path !== mf.filePath) {
|
|
77
|
+
upsertFromFile(mf);
|
|
78
|
+
stats.updated++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const row of existing) {
|
|
82
|
+
if (!seen.has(row.id) || !fs.existsSync(row.file_path)) {
|
|
83
|
+
deleteFromIndex(row.id);
|
|
84
|
+
stats.removed++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return stats;
|
|
88
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { paths } from "../paths.js";
|
|
4
|
+
import { parseRawFrontmatter, normalizeFrontmatter, isTaxonomyType, serialize, } from "./markdown.js";
|
|
5
|
+
/**
|
|
6
|
+
* v1 → v2 file migration (V2-DESIGN §19). Explicit only — run via
|
|
7
|
+
* `open-memex migrate --to-v2`. Converts frontmatter in place:
|
|
8
|
+
*
|
|
9
|
+
* - scope rename: v1 `user` → v2 `personal` (scope, scope_key, and the
|
|
10
|
+
* storage dir `memories/user/` → `memories/personal/`)
|
|
11
|
+
* - times: epoch ms → RFC 3339
|
|
12
|
+
* - `priority: N` → `importance: low|normal|high`
|
|
13
|
+
* - `type: instruction` → `type: knowledge` + `role: instruction`
|
|
14
|
+
* - unknown v1 types → `knowledge` (v2 taxonomy, §3.1)
|
|
15
|
+
* - adds `schema_version: 2`, `visibility`, `role`, `status`
|
|
16
|
+
*
|
|
17
|
+
* Legacy `my-o-memory` data roots (§19/D13: `.my-o-memory/` →
|
|
18
|
+
* `.open-memex/`, `~/.my-o-memory/` → `~/.open-memex/`) are merged into the
|
|
19
|
+
* current root; the old dir is kept as a dated backup.
|
|
20
|
+
*/
|
|
21
|
+
const V2_MARKER = /^schema_version:\s*2(\s|$)/m;
|
|
22
|
+
export function isV2File(raw) {
|
|
23
|
+
return V2_MARKER.test(raw);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Plan the v1 → v2 conversion of one file. Returns null when the file is
|
|
27
|
+
* already v2 (nothing to do). Pure — no disk writes.
|
|
28
|
+
*/
|
|
29
|
+
export function planConversion(filePath, memoriesRoot) {
|
|
30
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
31
|
+
if (isV2File(raw))
|
|
32
|
+
return null;
|
|
33
|
+
const parsed = parseRawFrontmatter(raw);
|
|
34
|
+
if (!parsed)
|
|
35
|
+
return null;
|
|
36
|
+
const { rawFm, body } = parsed;
|
|
37
|
+
const fm = normalizeFrontmatter(rawFm);
|
|
38
|
+
const changes = [];
|
|
39
|
+
// scope rename
|
|
40
|
+
if (rawFm.scope_kind === "user" || rawFm.scope_key === "user") {
|
|
41
|
+
changes.push("scope user → personal");
|
|
42
|
+
}
|
|
43
|
+
else if (!rawFm.scope) {
|
|
44
|
+
changes.push(`scope → ${fm.scope} (default)`);
|
|
45
|
+
}
|
|
46
|
+
// storage dir move follows scope_key
|
|
47
|
+
const toPath = path.join(memoriesRoot, fm.scope_key, path.basename(filePath));
|
|
48
|
+
if (toPath !== filePath) {
|
|
49
|
+
changes.push(`file moved: ${path.basename(path.dirname(filePath))}/ → ${fm.scope_key}/`);
|
|
50
|
+
}
|
|
51
|
+
// visibility default
|
|
52
|
+
if (!rawFm.visibility) {
|
|
53
|
+
changes.push(`visibility → ${fm.visibility} (default)`);
|
|
54
|
+
}
|
|
55
|
+
// type taxonomy
|
|
56
|
+
const rawType = rawFm.type;
|
|
57
|
+
if (rawType === "instruction") {
|
|
58
|
+
changes.push("type instruction → knowledge, role → instruction (D11)");
|
|
59
|
+
}
|
|
60
|
+
else if (typeof rawType === "string" && rawType && !isTaxonomyType(rawType)) {
|
|
61
|
+
changes.push(`type "${rawType}" → knowledge (not in v2 taxonomy)`);
|
|
62
|
+
fm.type = "knowledge";
|
|
63
|
+
}
|
|
64
|
+
else if (!rawType) {
|
|
65
|
+
changes.push(`type → ${fm.type} (default)`);
|
|
66
|
+
}
|
|
67
|
+
// priority → importance
|
|
68
|
+
if (typeof rawFm.priority === "number") {
|
|
69
|
+
changes.push(`priority ${rawFm.priority} → importance ${fm.importance}`);
|
|
70
|
+
}
|
|
71
|
+
else if (!rawFm.importance) {
|
|
72
|
+
changes.push(`importance → ${fm.importance} (default)`);
|
|
73
|
+
}
|
|
74
|
+
// times
|
|
75
|
+
if (typeof rawFm.created_at === "number") {
|
|
76
|
+
changes.push("created_at/updated_at: epoch ms → RFC 3339");
|
|
77
|
+
}
|
|
78
|
+
// status default
|
|
79
|
+
if (!rawFm.status) {
|
|
80
|
+
changes.push(`status → ${fm.status} (default)`);
|
|
81
|
+
}
|
|
82
|
+
changes.push("schema_version → 2");
|
|
83
|
+
return { fm, body, fromPath: filePath, toPath, changes };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Merge a legacy `my-o-memory` data root into the current `open-memex` root.
|
|
87
|
+
* The old dir is renamed to a dated backup — never deleted. Returns the
|
|
88
|
+
* backup path, or null when no legacy dir exists.
|
|
89
|
+
*/
|
|
90
|
+
function migrateLegacyDataRoot(dryRun) {
|
|
91
|
+
const { root } = paths();
|
|
92
|
+
const legacy = path.join(path.dirname(root), "my-o-memory");
|
|
93
|
+
if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory())
|
|
94
|
+
return null;
|
|
95
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
96
|
+
const backup = `${legacy}.backup-${stamp}`;
|
|
97
|
+
if (!dryRun) {
|
|
98
|
+
const legacyMem = path.join(legacy, "memories");
|
|
99
|
+
if (fs.existsSync(legacyMem)) {
|
|
100
|
+
for (const entry of fs.readdirSync(legacyMem)) {
|
|
101
|
+
const src = path.join(legacyMem, entry);
|
|
102
|
+
const dst = path.join(root, "memories", entry);
|
|
103
|
+
if (!fs.existsSync(dst)) {
|
|
104
|
+
fs.renameSync(src, dst);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// merge file-by-file, never overwrite
|
|
108
|
+
for (const name of fs.readdirSync(src)) {
|
|
109
|
+
const s = path.join(src, name);
|
|
110
|
+
const d = path.join(dst, name);
|
|
111
|
+
if (!fs.existsSync(d))
|
|
112
|
+
fs.renameSync(s, d);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
fs.renameSync(legacy, backup);
|
|
118
|
+
}
|
|
119
|
+
return backup;
|
|
120
|
+
}
|
|
121
|
+
export function migrateV2(opts) {
|
|
122
|
+
const { memories: memoriesRoot } = paths();
|
|
123
|
+
const result = {
|
|
124
|
+
scanned: 0,
|
|
125
|
+
converted: 0,
|
|
126
|
+
skippedV2: 0,
|
|
127
|
+
plans: [],
|
|
128
|
+
legacyBackup: null,
|
|
129
|
+
};
|
|
130
|
+
result.legacyBackup = migrateLegacyDataRoot(opts.dryRun);
|
|
131
|
+
if (!fs.existsSync(memoriesRoot))
|
|
132
|
+
return result;
|
|
133
|
+
for (const entry of fs.readdirSync(memoriesRoot, { withFileTypes: true })) {
|
|
134
|
+
if (!entry.isDirectory())
|
|
135
|
+
continue;
|
|
136
|
+
const dir = path.join(memoriesRoot, entry.name);
|
|
137
|
+
for (const name of fs.readdirSync(dir)) {
|
|
138
|
+
if (!name.endsWith(".md"))
|
|
139
|
+
continue;
|
|
140
|
+
const filePath = path.join(dir, name);
|
|
141
|
+
result.scanned++;
|
|
142
|
+
const plan = planConversion(filePath, memoriesRoot);
|
|
143
|
+
if (!plan) {
|
|
144
|
+
result.skippedV2++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!opts.dryRun) {
|
|
148
|
+
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
149
|
+
fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
|
|
150
|
+
if (plan.toPath !== plan.fromPath)
|
|
151
|
+
fs.unlinkSync(plan.fromPath);
|
|
152
|
+
}
|
|
153
|
+
result.converted++;
|
|
154
|
+
result.plans.push(plan);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
|
+
import { addMemory, searchMemories, listMemories, supersedeMemory, forgetMemory, memoryAddArgs, memorySearchArgs, memoryListArgs, memorySupersedeArgs, memoryForgetArgs, TOOL_DESCRIPTIONS, } from "./ops.js";
|
|
3
|
+
export function makeTools(getScope, cfg) {
|
|
4
|
+
const memory_add = tool({
|
|
5
|
+
description: TOOL_DESCRIPTIONS.memory_add,
|
|
6
|
+
args: memoryAddArgs,
|
|
7
|
+
async execute(args) {
|
|
8
|
+
return addMemory(getScope, cfg, args);
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
const memory_search = tool({
|
|
12
|
+
description: TOOL_DESCRIPTIONS.memory_search,
|
|
13
|
+
args: memorySearchArgs,
|
|
14
|
+
async execute(args) {
|
|
15
|
+
return searchMemories(getScope, args);
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
const memory_list = tool({
|
|
19
|
+
description: TOOL_DESCRIPTIONS.memory_list,
|
|
20
|
+
args: memoryListArgs,
|
|
21
|
+
async execute(args) {
|
|
22
|
+
return listMemories(getScope, args);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
const memory_supersede = tool({
|
|
26
|
+
description: TOOL_DESCRIPTIONS.memory_supersede,
|
|
27
|
+
args: memorySupersedeArgs,
|
|
28
|
+
async execute(args) {
|
|
29
|
+
return supersedeMemory(cfg, args);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const memory_forget = tool({
|
|
33
|
+
description: TOOL_DESCRIPTIONS.memory_forget,
|
|
34
|
+
args: memoryForgetArgs,
|
|
35
|
+
async execute(args) {
|
|
36
|
+
return forgetMemory(args);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
return { memory_add, memory_search, memory_list, memory_forget, memory_supersede };
|
|
40
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic memory operations.
|
|
3
|
+
*
|
|
4
|
+
* The same functions back both the opencode plugin tools
|
|
5
|
+
* (`src/tools/memory.ts`) and the generic MCP server (`src/mcp.ts`).
|
|
6
|
+
* They take plain validated args and return a plain { title, output }
|
|
7
|
+
* result; each host adapts that to its own tool-result shape.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { PERSONAL_SCOPE } from "../scope.js";
|
|
11
|
+
import { search, list } from "../retrieve/search.js";
|
|
12
|
+
import { writeMemoryFile, deleteMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, } from "../store/markdown.js";
|
|
13
|
+
import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
|
|
14
|
+
import { findDuplicates, supersede } from "../store/lifecycle.js";
|
|
15
|
+
import { db } from "../store/db.js";
|
|
16
|
+
import { redact } from "../redact.js";
|
|
17
|
+
/** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
|
|
18
|
+
export const TOOL_DESCRIPTIONS = {
|
|
19
|
+
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.",
|
|
20
|
+
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 — try a few keyword variants, including the user's own language, when the first search comes up empty.",
|
|
21
|
+
memory_list: "List memories in a scope, newest first. Useful for browsing what is remembered, or verifying that a save landed.",
|
|
22
|
+
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.",
|
|
23
|
+
memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
|
|
24
|
+
};
|
|
25
|
+
/** Shared zod input shapes (raw shape, not z.object — hosts wrap as needed). */
|
|
26
|
+
export const scopeArg = z
|
|
27
|
+
.enum(["project", "personal", "user"])
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Memory scope. `project` = tied to this repo. `personal` = global across all your projects. `user` is a deprecated alias of `personal`. Default: project.");
|
|
30
|
+
export const memoryAddArgs = {
|
|
31
|
+
content: z.string().min(1).describe("The fact to remember. One idea per memory."),
|
|
32
|
+
type: z
|
|
33
|
+
.enum(MEMORY_TYPE_TAXONOMY)
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Category of memory. Default: note."),
|
|
36
|
+
scope: scopeArg,
|
|
37
|
+
tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
|
|
38
|
+
};
|
|
39
|
+
export const memorySearchArgs = {
|
|
40
|
+
query: z
|
|
41
|
+
.string()
|
|
42
|
+
.min(1)
|
|
43
|
+
.describe("Free-text query. File paths, error strings, identifiers work well."),
|
|
44
|
+
scope: z
|
|
45
|
+
.enum(["project", "personal", "user", "both"])
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Which scope(s) to search. Default: both."),
|
|
48
|
+
type: z.string().optional().describe("Restrict to memories of this type."),
|
|
49
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
50
|
+
};
|
|
51
|
+
export const memoryListArgs = {
|
|
52
|
+
scope: scopeArg,
|
|
53
|
+
type: z.string().optional(),
|
|
54
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
55
|
+
};
|
|
56
|
+
export const memorySupersedeArgs = {
|
|
57
|
+
id: z.string().min(1).describe("ID of the memory being replaced."),
|
|
58
|
+
content: z.string().min(1).describe("The new, corrected content."),
|
|
59
|
+
type: z
|
|
60
|
+
.enum(MEMORY_TYPE_TAXONOMY)
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("Category of the new memory. Defaults to the old memory's type."),
|
|
63
|
+
tags: z.array(z.string()).optional().describe("Optional tags for the new memory."),
|
|
64
|
+
};
|
|
65
|
+
export const memoryForgetArgs = {
|
|
66
|
+
id: z.string().min(1),
|
|
67
|
+
};
|
|
68
|
+
function resolveScope(getScope, kind) {
|
|
69
|
+
return kind === "personal" || kind === "user" ? PERSONAL_SCOPE : getScope();
|
|
70
|
+
}
|
|
71
|
+
function buildFrontmatter(s, body) {
|
|
72
|
+
const rfc = msToRfc3339(Date.now());
|
|
73
|
+
return {
|
|
74
|
+
id: ulid(),
|
|
75
|
+
schema_version: 2,
|
|
76
|
+
scope_key: s.key,
|
|
77
|
+
scope: s.kind === "project" ? "project" : "personal",
|
|
78
|
+
visibility: s.kind === "project" ? "internal" : "private",
|
|
79
|
+
project_name: s.projectName,
|
|
80
|
+
type: body.type,
|
|
81
|
+
role: "knowledge",
|
|
82
|
+
importance: "normal",
|
|
83
|
+
status: "active",
|
|
84
|
+
tags: body.tags,
|
|
85
|
+
source: body.source,
|
|
86
|
+
created_at: rfc,
|
|
87
|
+
updated_at: rfc,
|
|
88
|
+
supersedes: null,
|
|
89
|
+
superseded_by: null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export async function addMemory(getScope, cfg, args) {
|
|
93
|
+
const { content: redacted, hadSecret, matchedPattern } = redact(args.content, cfg.redactPatterns);
|
|
94
|
+
const secretNote = hadSecret
|
|
95
|
+
? `\nNote: content matched a secret pattern (${matchedPattern}); it was saved with the secret masked (first 4 chars kept, rest replaced with x).`
|
|
96
|
+
: "";
|
|
97
|
+
const s = resolveScope(getScope, args.scope);
|
|
98
|
+
// Dedup on write (§3.4): identical content is idempotent; near-duplicates
|
|
99
|
+
// are reported so the caller can supersede instead of duplicating.
|
|
100
|
+
const dups = findDuplicates(s.key, redacted);
|
|
101
|
+
if (dups.exact) {
|
|
102
|
+
return {
|
|
103
|
+
title: "memory: already exists",
|
|
104
|
+
output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const fm = buildFrontmatter(s, {
|
|
108
|
+
type: args.type ?? "fact",
|
|
109
|
+
tags: args.tags ?? [],
|
|
110
|
+
source: "tool",
|
|
111
|
+
});
|
|
112
|
+
const { filePath } = writeMemoryFile(fm, redacted);
|
|
113
|
+
const mf = readMemoryFile(filePath);
|
|
114
|
+
if (mf)
|
|
115
|
+
upsertFromFile(mf);
|
|
116
|
+
let output = `Saved to ${s.kind} scope (${s.key}) as ${fm.type}. id=${fm.id}${secretNote}`;
|
|
117
|
+
for (const n of dups.near) {
|
|
118
|
+
output += `\nNote: similar memory exists (score ${n.score.toFixed(2)}): id=${n.id} — ${n.snippet}. Use memory_supersede if this replaces it.`;
|
|
119
|
+
}
|
|
120
|
+
return { title: `memory: saved ${fm.id}`, output };
|
|
121
|
+
}
|
|
122
|
+
export async function searchMemories(getScope, args) {
|
|
123
|
+
const project = getScope();
|
|
124
|
+
const scopeKeys = args.scope === "personal" || args.scope === "user"
|
|
125
|
+
? [PERSONAL_SCOPE.key]
|
|
126
|
+
: args.scope === "project"
|
|
127
|
+
? [project.key]
|
|
128
|
+
: [project.key, PERSONAL_SCOPE.key];
|
|
129
|
+
const hits = search(args.query, {
|
|
130
|
+
scopeKeys,
|
|
131
|
+
limit: args.limit,
|
|
132
|
+
type: args.type,
|
|
133
|
+
});
|
|
134
|
+
if (hits.length === 0) {
|
|
135
|
+
return {
|
|
136
|
+
title: "memory: 0 results",
|
|
137
|
+
output: `No memories matched "${args.query}".`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const lines = hits.map((h) => `- [${h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project"}/${h.type}] id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
141
|
+
return { title: `memory: ${hits.length} result(s)`, output: lines.join("\n") };
|
|
142
|
+
}
|
|
143
|
+
export async function listMemories(getScope, args) {
|
|
144
|
+
const s = resolveScope(getScope, args.scope);
|
|
145
|
+
const hits = list(s.key, { type: args.type, limit: args.limit });
|
|
146
|
+
if (hits.length === 0) {
|
|
147
|
+
return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
|
|
148
|
+
}
|
|
149
|
+
const lines = hits.map((h) => `- [${h.type}] id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
150
|
+
return { title: `memory: ${hits.length} in ${s.key}`, output: lines.join("\n") };
|
|
151
|
+
}
|
|
152
|
+
export async function supersedeMemory(cfg, args) {
|
|
153
|
+
const { content: redacted, hadSecret, matchedPattern } = redact(args.content, cfg.redactPatterns);
|
|
154
|
+
const secretNote = hadSecret ? ` (secret masked: ${matchedPattern})` : "";
|
|
155
|
+
try {
|
|
156
|
+
const { oldMf, newMf } = supersede(args.id, {
|
|
157
|
+
body: redacted,
|
|
158
|
+
type: args.type,
|
|
159
|
+
tags: args.tags,
|
|
160
|
+
source: "tool",
|
|
161
|
+
});
|
|
162
|
+
upsertFromFile(oldMf);
|
|
163
|
+
upsertFromFile(newMf);
|
|
164
|
+
return {
|
|
165
|
+
title: `memory: superseded ${oldMf.fm.id}`,
|
|
166
|
+
output: `Replaced ${oldMf.fm.id} with ${newMf.fm.id} (old kept as history).${secretNote}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
return {
|
|
171
|
+
title: "memory: supersede failed",
|
|
172
|
+
output: e.message,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export async function forgetMemory(args) {
|
|
177
|
+
const row = db()
|
|
178
|
+
.prepare(`SELECT scope_key FROM memories WHERE id = ?`)
|
|
179
|
+
.get(args.id);
|
|
180
|
+
if (!row) {
|
|
181
|
+
return { title: "memory: not found", output: `No memory with id ${args.id}.` };
|
|
182
|
+
}
|
|
183
|
+
deleteMemoryFile(row.scope_key, args.id);
|
|
184
|
+
deleteFromIndex(args.id);
|
|
185
|
+
return { title: "memory: forgotten", output: `Deleted ${args.id}.` };
|
|
186
|
+
}
|
package/docs/V2-DESIGN.md
CHANGED
|
@@ -578,6 +578,25 @@ requirement: personal data never touches third-party services). Benchmarks to tr
|
|
|
578
578
|
auto-discovers `.vscode/mcp.json` and `.cursor/mcp.json`, so repos already set up for
|
|
579
579
|
VS Code get VS support for free; the explicit `.mcp.json` is the source-controllable
|
|
580
580
|
option. 2026-09-27.*
|
|
581
|
+
- **D21** — The published npm package ships pre-compiled JS (`tsc -p tsconfig.build.json`
|
|
582
|
+
→ `dist/`, via `prepublishOnly`; bin points at `dist/cli.js`). *Rationale: Node's
|
|
583
|
+
`--experimental-strip-types` refuses files under `node_modules`
|
|
584
|
+
(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so the old launcher
|
|
585
|
+
(`bin/open-memex.js` re-execing `src/cli.ts`) crashed on every global install —
|
|
586
|
+
reported 2026-09-27 on Node v22.12.0. Development stays build-free (`npm run cli`
|
|
587
|
+
/ `npm run mcp` run `src/` directly); `doctor`'s MCP self-check resolves its server
|
|
588
|
+
entry the same way it is running (`dist/mcp.js` vs `src/mcp.ts`). The opencode
|
|
589
|
+
native plugin still loads `src/index.ts` (Bun strips types anywhere). 2026-09-27.*
|
|
590
|
+
- **D22** — `init` writes the Copilot memory instructions to the **user level** by
|
|
591
|
+
default (`~/.copilot/copilot-instructions.md`; `%USERPROFILE%\copilot-
|
|
592
|
+
instructions.md` for Visual Studio 2026) instead of the repo-level
|
|
593
|
+
`.github/copilot-instructions.md`. *Rationale: the repo-level file is checked in,
|
|
594
|
+
so teammates without open-memex get Copilot errors about missing `memory_*`
|
|
595
|
+
tools. The user-level location is GitHub's official personal-instructions slot
|
|
596
|
+
(highest priority, all projects, never in a repo). `--instructions project`
|
|
597
|
+
keeps the old repo-level behavior for teams where everyone uses open-memex.
|
|
598
|
+
The instructions carry a guard clause ("ignore this section when the
|
|
599
|
+
`open-memex` MCP server is not available") as cheap insurance. 2026-09-27.*
|
|
581
600
|
|
|
582
601
|
## Open Questions
|
|
583
602
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-memex",
|
|
3
|
-
"version": "0.3.0-alpha",
|
|
3
|
+
"version": "0.3.0-alpha.3",
|
|
4
4
|
"description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"typecheck": "tsc --noEmit",
|
|
18
18
|
"cli": "node --experimental-strip-types src/cli.ts",
|
|
19
|
-
"mcp": "node --experimental-strip-types src/mcp.ts"
|
|
19
|
+
"mcp": "node --experimental-strip-types src/mcp.ts",
|
|
20
|
+
"build": "tsc -p tsconfig.build.json",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
20
22
|
},
|
|
21
23
|
"dependencies": {
|
|
22
24
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
"typescript": "^5.6.0"
|
|
33
35
|
},
|
|
34
36
|
"bin": {
|
|
35
|
-
"open-memex": "
|
|
37
|
+
"open-memex": "dist/cli.js"
|
|
36
38
|
},
|
|
37
39
|
"engines": {
|
|
38
40
|
"node": ">=22.6"
|
package/src/cli.ts
CHANGED
|
@@ -20,33 +20,39 @@ import { paths } from "./paths.ts";
|
|
|
20
20
|
import { redact } from "./redact.ts";
|
|
21
21
|
import { resolveMcpCommand } from "./init.ts";
|
|
22
22
|
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
23
25
|
|
|
24
|
-
function usage(): never {
|
|
26
|
+
function usage(exitCode = 1): never {
|
|
25
27
|
console.log(`open-memex CLI
|
|
26
28
|
|
|
27
29
|
Usage:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
open-memex where
|
|
31
|
+
open-memex list [--scope project|personal] [--type T] [--limit N]
|
|
32
|
+
open-memex search "query" [--scope project|personal|both] [--type T] [--limit N]
|
|
33
|
+
open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
|
|
34
|
+
open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
|
|
35
|
+
open-memex status <id> active|deprecated|retracted|archived
|
|
36
|
+
open-memex forget <id>
|
|
37
|
+
open-memex reindex
|
|
38
|
+
open-memex scopes
|
|
39
|
+
open-memex migrate [--from <key>] [--to <key>]
|
|
38
40
|
[--dry-run] [--on-conflict newer|overwrite|skip]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
open-memex migrate --to-v2 [--dry-run]
|
|
42
|
+
open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
|
|
43
|
+
open-memex init [--client vscode|cursor|opencode|visualstudio]
|
|
44
|
+
[--instructions personal|project] [--force] [--yes]
|
|
45
|
+
open-memex config [set <key> <value>]
|
|
46
|
+
open-memex capture --dry-run "text"
|
|
47
|
+
open-memex doctor
|
|
45
48
|
|
|
46
49
|
One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) writes
|
|
47
50
|
the MCP config for your editor (\`.vscode/mcp.json\`, \`.cursor/mcp.json\`,
|
|
48
|
-
\`opencode.jsonc\`, or Visual Studio's solution-level \`.mcp.json\`)
|
|
49
|
-
|
|
51
|
+
\`opencode.jsonc\`, or Visual Studio's solution-level \`.mcp.json\`) — no copy-paste
|
|
52
|
+
needed. The Copilot memory instructions default to your user-level
|
|
53
|
+
\`~/.copilot/copilot-instructions.md\` (all projects, never checked into a repo);
|
|
54
|
+
\`--instructions project\` writes \`.github/copilot-instructions.md\` instead for
|
|
55
|
+
teams where everyone uses open-memex.
|
|
50
56
|
Existing files are merged, never clobbered; re-running is safe. On a terminal it
|
|
51
57
|
asks which editor to set up and a couple of settings (keyword capture, first-turn
|
|
52
58
|
injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
|
|
@@ -66,7 +72,7 @@ git remote after memories were already stored under the cwd-based key.
|
|
|
66
72
|
\`migrate --to-v2\` converts v1 memory files to the v2 format (§19):
|
|
67
73
|
user→personal scope rename, epoch→RFC 3339 times, priority→importance,
|
|
68
74
|
type: instruction→role split. Always preview with --dry-run first.`);
|
|
69
|
-
process.exit(
|
|
75
|
+
process.exit(exitCode);
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
function parseFlags(argv: string[]): Record<string, string> {
|
|
@@ -179,7 +185,16 @@ function printMcpConfig(client: string): never {
|
|
|
179
185
|
|
|
180
186
|
async function main() {
|
|
181
187
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
182
|
-
if (!cmd) usage();
|
|
188
|
+
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") usage(0);
|
|
189
|
+
|
|
190
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
191
|
+
// package.json sits two levels above this file in both layouts
|
|
192
|
+
// (src/cli.ts and dist/cli.js).
|
|
193
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
194
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
195
|
+
console.log(`open-memex ${pkg.version}`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
183
198
|
|
|
184
199
|
const cfg = loadConfig();
|
|
185
200
|
const project = resolveProjectScope(process.cwd());
|
|
@@ -225,6 +240,7 @@ async function main() {
|
|
|
225
240
|
client: flags["client"],
|
|
226
241
|
force: flags["force"] === "true",
|
|
227
242
|
yes: flags["yes"] === "true",
|
|
243
|
+
instructions: flags["instructions"],
|
|
228
244
|
});
|
|
229
245
|
return;
|
|
230
246
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -73,15 +73,26 @@ function storageCheck(): Check {
|
|
|
73
73
|
function mcpCheck(): Promise<Check> {
|
|
74
74
|
const name = "mcp";
|
|
75
75
|
return new Promise((resolve) => {
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// D21: the installed package runs compiled JS from dist/ (Node refuses
|
|
77
|
+
// --experimental-strip-types for files under node_modules), while a source
|
|
78
|
+
// checkout runs src/ directly. Resolve the server entry the same way this
|
|
79
|
+
// file itself is running.
|
|
80
|
+
const here = fileURLToPath(import.meta.url);
|
|
81
|
+
const fromDist = here.endsWith(`dist${path.sep}doctor.js`);
|
|
82
|
+
const server = fromDist
|
|
83
|
+
? path.join(path.dirname(here), "mcp.js")
|
|
84
|
+
: path.join(path.dirname(path.dirname(here)), "src", "mcp.ts");
|
|
78
85
|
if (!fs.existsSync(server)) {
|
|
79
86
|
resolve({ name, ok: false, detail: `server entry not found: ${server}` });
|
|
80
87
|
return;
|
|
81
88
|
}
|
|
82
|
-
const child = spawn(
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
const child = spawn(
|
|
90
|
+
process.execPath,
|
|
91
|
+
fromDist ? [server] : ["--experimental-strip-types", server],
|
|
92
|
+
{
|
|
93
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
94
|
+
},
|
|
95
|
+
);
|
|
85
96
|
const done = (c: Check) => {
|
|
86
97
|
clearTimeout(timer);
|
|
87
98
|
try {
|