open-memex 0.3.0 → 0.4.0-alpha.10
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 +20 -1
- package/README.md +117 -9
- package/README.zh-CN.md +103 -9
- package/dist/cli.js +583 -14
- package/dist/config.js +12 -0
- package/dist/distill-agents.js +55 -0
- package/dist/doctor.js +1 -1
- package/dist/export.js +157 -0
- package/dist/github.js +215 -0
- package/dist/index.js +6 -0
- package/dist/init.js +43 -18
- package/dist/mcp.js +119 -12
- package/dist/paths.js +31 -0
- package/dist/providers/git.js +142 -0
- package/dist/retrieve/inject.js +2 -2
- package/dist/retrieve/search.js +31 -6
- package/dist/review.js +437 -0
- package/dist/store/db.js +3 -2
- package/dist/store/lifecycle.js +28 -13
- package/dist/store/markdown.js +57 -14
- package/dist/store/sync.js +74 -6
- package/dist/submit.js +347 -0
- package/dist/tools/ops.js +171 -8
- package/docs/CURATOR.md +59 -0
- package/docs/USER-GUIDE.md +197 -0
- package/docs/USER-GUIDE.zh-CN.md +161 -0
- package/docs/V2-DESIGN.md +287 -13
- package/package.json +1 -1
- package/scripts/smoke-mcp.ts +2 -2
- package/src/cli.ts +608 -15
- package/src/config.ts +26 -0
- package/src/distill-agents.ts +70 -0
- package/src/doctor.ts +1 -1
- package/src/export.ts +206 -0
- package/src/github.ts +257 -0
- package/src/index.ts +6 -0
- package/src/init.ts +43 -17
- package/src/mcp.ts +166 -11
- package/src/paths.ts +32 -0
- package/src/providers/git.ts +191 -0
- package/src/retrieve/inject.ts +2 -2
- package/src/retrieve/search.ts +33 -6
- package/src/review.ts +504 -0
- package/src/store/db.ts +3 -2
- package/src/store/lifecycle.ts +30 -12
- package/src/store/markdown.ts +91 -14
- package/src/store/sync.ts +85 -4
- package/src/submit.ts +424 -0
- package/src/tools/ops.ts +203 -9
package/dist/tools/ops.js
CHANGED
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
* They take plain validated args and return a plain { title, output }
|
|
7
7
|
* result; each host adapts that to its own tool-result shape.
|
|
8
8
|
*/
|
|
9
|
+
import fs from "node:fs";
|
|
9
10
|
import { z } from "zod";
|
|
10
11
|
import { PERSONAL_SCOPE } from "../scope.js";
|
|
11
|
-
import { search, list } from "../retrieve/search.js";
|
|
12
|
-
import { writeMemoryFile,
|
|
12
|
+
import { search, list, hitStateLabel } from "../retrieve/search.js";
|
|
13
|
+
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, } from "../store/markdown.js";
|
|
13
14
|
import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
|
|
14
15
|
import { findDuplicates, supersede } from "../store/lifecycle.js";
|
|
15
16
|
import { db } from "../store/db.js";
|
|
16
17
|
import { redact } from "../redact.js";
|
|
18
|
+
import { getSyncStatus, formatSyncStatus, submitMemories } from "../submit.js";
|
|
19
|
+
import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.js";
|
|
20
|
+
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory, } from "../review.js";
|
|
17
21
|
/** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
|
|
18
22
|
export const TOOL_DESCRIPTIONS = {
|
|
19
23
|
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.",
|
|
@@ -21,6 +25,12 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
21
25
|
memory_list: "List memories in a scope, newest first. Useful for browsing what is remembered, or verifying that a save landed.",
|
|
22
26
|
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
27
|
memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
|
|
28
|
+
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 and at task checkpoints, then ask the user which drafts to sync. The user may also trigger this flow by saying 'sync memory' (or '同步记忆').",
|
|
29
|
+
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.",
|
|
30
|
+
memory_propose: "Copy personal memories into the project outbox as review drafts. The personal originals stay put.",
|
|
31
|
+
memory_promote: "Advance a project memory one step up the review ladder (proposed → approved → published), or reject it with a note. Rejected memories are never deleted — they can be revised and resubmitted.",
|
|
32
|
+
memory_resolve: "List git-conflicted memory files, or attempt a field-level 3-way merge of one. Semantic conflicts are reported, never auto-resolved.",
|
|
33
|
+
memory_pr_status: "Read the current branch's GitHub PR and map its review state onto each in-repo memory: merged PR → published, PR approval → approved (approved_by = reviewer), changes-requested → suggestion only. Report by default; apply=true performs the mapped transitions locally (no push).",
|
|
24
34
|
};
|
|
25
35
|
/** Shared zod input shapes (raw shape, not z.object — hosts wrap as needed). */
|
|
26
36
|
export const scopeArg = z
|
|
@@ -32,9 +42,13 @@ export const memoryAddArgs = {
|
|
|
32
42
|
type: z
|
|
33
43
|
.enum(MEMORY_TYPE_TAXONOMY)
|
|
34
44
|
.optional()
|
|
35
|
-
.describe("Category of memory. Default:
|
|
45
|
+
.describe("Category of memory. Default: fact."),
|
|
36
46
|
scope: scopeArg,
|
|
37
47
|
tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
|
|
48
|
+
source: z
|
|
49
|
+
.string()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Where this memory came from. Default: tool. Pass 'inference' for agent-proposed captures at checkpoints (V2-DESIGN §3.5)."),
|
|
38
52
|
};
|
|
39
53
|
export const memorySearchArgs = {
|
|
40
54
|
query: z
|
|
@@ -65,6 +79,44 @@ export const memorySupersedeArgs = {
|
|
|
65
79
|
export const memoryForgetArgs = {
|
|
66
80
|
id: z.string().min(1),
|
|
67
81
|
};
|
|
82
|
+
export const memoryStatusArgs = {};
|
|
83
|
+
export const memorySubmitArgs = {
|
|
84
|
+
ids: z.array(z.string().min(1)).min(1).describe("Outbox draft ids to submit."),
|
|
85
|
+
branch: z
|
|
86
|
+
.string()
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Create this branch and submit onto it. If omitted, submit stays on the current branch — branches are never auto-created. Only pass this when the user explicitly approved the full chain (branch + push + PR)."),
|
|
89
|
+
base: z
|
|
90
|
+
.string()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe("PR base branch override. Default: the branch the submit ran on."),
|
|
93
|
+
};
|
|
94
|
+
export const memoryProposeArgs = {
|
|
95
|
+
ids: z.array(z.string().min(1)).min(1).describe("Personal memory ids to copy into the project outbox."),
|
|
96
|
+
localApprove: z
|
|
97
|
+
.boolean()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe("Single-developer shortcut: mark the copies approved immediately."),
|
|
100
|
+
};
|
|
101
|
+
export const memoryPromoteArgs = {
|
|
102
|
+
id: z.string().min(1).describe("Project memory id."),
|
|
103
|
+
reject: z.boolean().optional().describe("Reject instead of advancing."),
|
|
104
|
+
resubmit: z.boolean().optional().describe("Move rejected back to proposed for another round."),
|
|
105
|
+
note: z.string().optional().describe("Review note recorded on reject."),
|
|
106
|
+
by: z.string().optional().describe("Reviewer name override."),
|
|
107
|
+
};
|
|
108
|
+
export const memoryResolveArgs = {
|
|
109
|
+
target: z
|
|
110
|
+
.string()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe("Memory id or file path to resolve. Omit to list conflicts."),
|
|
113
|
+
};
|
|
114
|
+
export const memoryPrStatusArgs = {
|
|
115
|
+
apply: z
|
|
116
|
+
.boolean()
|
|
117
|
+
.optional()
|
|
118
|
+
.describe("Perform the mapped review transitions locally (no push). Default: report only."),
|
|
119
|
+
};
|
|
68
120
|
function resolveScope(getScope, kind) {
|
|
69
121
|
return kind === "personal" || kind === "user" ? PERSONAL_SCOPE : getScope();
|
|
70
122
|
}
|
|
@@ -87,6 +139,12 @@ function buildFrontmatter(s, body) {
|
|
|
87
139
|
updated_at: rfc,
|
|
88
140
|
supersedes: null,
|
|
89
141
|
superseded_by: null,
|
|
142
|
+
review_state: "draft",
|
|
143
|
+
proposed_by: null,
|
|
144
|
+
approved_by: null,
|
|
145
|
+
derived_from: null,
|
|
146
|
+
review_note: null,
|
|
147
|
+
review_history: [],
|
|
90
148
|
};
|
|
91
149
|
}
|
|
92
150
|
export async function addMemory(getScope, cfg, args) {
|
|
@@ -107,7 +165,7 @@ export async function addMemory(getScope, cfg, args) {
|
|
|
107
165
|
const fm = buildFrontmatter(s, {
|
|
108
166
|
type: args.type ?? "fact",
|
|
109
167
|
tags: args.tags ?? [],
|
|
110
|
-
source: "tool",
|
|
168
|
+
source: args.source ?? "tool",
|
|
111
169
|
});
|
|
112
170
|
const { filePath } = writeMemoryFile(fm, redacted);
|
|
113
171
|
const mf = readMemoryFile(filePath);
|
|
@@ -137,7 +195,7 @@ export async function searchMemories(getScope, args) {
|
|
|
137
195
|
output: `No memories matched "${args.query}".`,
|
|
138
196
|
};
|
|
139
197
|
}
|
|
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()}`);
|
|
198
|
+
const lines = hits.map((h) => `- [${h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project"}/${h.type}]${hitStateLabel(h)} id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
141
199
|
return { title: `memory: ${hits.length} result(s)`, output: lines.join("\n") };
|
|
142
200
|
}
|
|
143
201
|
export async function listMemories(getScope, args) {
|
|
@@ -146,7 +204,7 @@ export async function listMemories(getScope, args) {
|
|
|
146
204
|
if (hits.length === 0) {
|
|
147
205
|
return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
|
|
148
206
|
}
|
|
149
|
-
const lines = hits.map((h) => `- [${h.type}] id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
207
|
+
const lines = hits.map((h) => `- [${h.type}]${hitStateLabel(h)} id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
150
208
|
return { title: `memory: ${hits.length} in ${s.key}`, output: lines.join("\n") };
|
|
151
209
|
}
|
|
152
210
|
export async function supersedeMemory(cfg, args) {
|
|
@@ -175,12 +233,117 @@ export async function supersedeMemory(cfg, args) {
|
|
|
175
233
|
}
|
|
176
234
|
export async function forgetMemory(args) {
|
|
177
235
|
const row = db()
|
|
178
|
-
.prepare(`SELECT
|
|
236
|
+
.prepare(`SELECT file_path FROM memories WHERE id = ?`)
|
|
179
237
|
.get(args.id);
|
|
180
238
|
if (!row) {
|
|
181
239
|
return { title: "memory: not found", output: `No memory with id ${args.id}.` };
|
|
182
240
|
}
|
|
183
|
-
|
|
241
|
+
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
242
|
+
if (row.file_path) {
|
|
243
|
+
try {
|
|
244
|
+
fs.unlinkSync(row.file_path);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
/* already gone */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
184
250
|
deleteFromIndex(args.id);
|
|
185
251
|
return { title: "memory: forgotten", output: `Deleted ${args.id}.` };
|
|
186
252
|
}
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// 2B/D26: review workflow ops (also exposed over MCP so agents can drive the
|
|
255
|
+
// sync loop: status → ask user → submit → push/PR on approval).
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
export async function statusMemories() {
|
|
258
|
+
const st = getSyncStatus();
|
|
259
|
+
const n = st.outbox.length +
|
|
260
|
+
st.inRepo.filter((e) => e.reviewState === "proposed" || e.reviewState === "approved").length;
|
|
261
|
+
return {
|
|
262
|
+
title: `memory: sync status (${n} pending)`,
|
|
263
|
+
output: formatSyncStatus(st),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
export async function submitMemoriesOp(args) {
|
|
267
|
+
const r = submitMemories(args.ids, { branch: args.branch, base: args.base });
|
|
268
|
+
const lines = [];
|
|
269
|
+
for (const s of r.submitted)
|
|
270
|
+
lines.push(`submitted ${s.id} [${s.reviewState}]`);
|
|
271
|
+
for (const id of r.skippedIdentical)
|
|
272
|
+
lines.push(`already on branch: ${id} (outbox copy removed)`);
|
|
273
|
+
lines.push(r.committed ? `committed on ${r.branch} (you are still on this branch).` : `nothing new to commit on ${r.branch}.`);
|
|
274
|
+
lines.push(`Next — ask the user: "want me to create a branch + push + open the PR, or will you handle it yourself?"`);
|
|
275
|
+
lines.push(`Never create branches, push, or open PRs without their explicit approval. If they handle it themselves, hand them these:`);
|
|
276
|
+
lines.push(` ${r.pushCommand}`);
|
|
277
|
+
for (const l of r.prCommand.split("\n"))
|
|
278
|
+
lines.push(` ${l}`);
|
|
279
|
+
return { title: `memory: submitted ${r.submitted.length}`, output: lines.join("\n") };
|
|
280
|
+
}
|
|
281
|
+
export async function proposeMemoriesOp(args) {
|
|
282
|
+
const batch = proposeMemories(args.ids, { localApprove: args.localApprove });
|
|
283
|
+
const lines = batch.map(({ sourceId, result: r }) => `proposed ${sourceId} → ${r.id} [${r.reviewState}] (outbox draft)`);
|
|
284
|
+
lines.push(`Next: open-memex sync-status, then submit the new ids to move them into a branch.`);
|
|
285
|
+
return { title: `memory: proposed ${batch.length}`, output: lines.join("\n") };
|
|
286
|
+
}
|
|
287
|
+
export async function promoteMemoryOp(args) {
|
|
288
|
+
const r = promoteMemory(args.id, {
|
|
289
|
+
reject: args.reject,
|
|
290
|
+
resubmit: args.resubmit,
|
|
291
|
+
note: args.note,
|
|
292
|
+
by: args.by,
|
|
293
|
+
});
|
|
294
|
+
const lines = [`promote ${r.id}: ${r.from} → ${r.to}`];
|
|
295
|
+
if (r.to === "rejected") {
|
|
296
|
+
lines.push(`not deleted — the file stays on the branch. Accept (close the PR), revise + resubmit, or keep as a [rejected] record.`);
|
|
297
|
+
}
|
|
298
|
+
if (r.history.length > 0) {
|
|
299
|
+
lines.push(`history (${r.history.length}):`);
|
|
300
|
+
lines.push(...formatReviewHistory(r.history));
|
|
301
|
+
}
|
|
302
|
+
return { title: `memory: ${r.id} → ${r.to}`, output: lines.join("\n") };
|
|
303
|
+
}
|
|
304
|
+
export async function resolveMemoryOp(args) {
|
|
305
|
+
if (!args.target) {
|
|
306
|
+
const conflicts = listConflicts();
|
|
307
|
+
if (conflicts.length === 0) {
|
|
308
|
+
return { title: "memory: no conflicts", output: "(no conflicted memory files)" };
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
title: `memory: ${conflicts.length} conflicted`,
|
|
312
|
+
output: conflicts.map((c) => `conflicted: ${c.id} ${c.filePath}`).join("\n"),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const outcome = resolveConflict(args.target);
|
|
316
|
+
if (!outcome.ok) {
|
|
317
|
+
const lines = [
|
|
318
|
+
`cannot auto-resolve ${outcome.filePath} — semantic conflicts need a human:`,
|
|
319
|
+
...outcome.conflicts.flatMap((c) => [
|
|
320
|
+
` ${c.field}:`,
|
|
321
|
+
` base: ${c.base}`,
|
|
322
|
+
` ours: ${c.ours}`,
|
|
323
|
+
` theirs: ${c.theirs}`,
|
|
324
|
+
]),
|
|
325
|
+
`Edit the file manually, then \`git add\` it. Nothing was written.`,
|
|
326
|
+
];
|
|
327
|
+
return { title: "memory: resolve blocked", output: lines.join("\n") };
|
|
328
|
+
}
|
|
329
|
+
const lines = [`resolved ${outcome.filePath}`];
|
|
330
|
+
if (outcome.autoMerged.length > 0)
|
|
331
|
+
lines.push(` auto-merged: ${outcome.autoMerged.join(", ")}`);
|
|
332
|
+
return { title: "memory: resolved", output: lines.join("\n") };
|
|
333
|
+
}
|
|
334
|
+
export async function prStatusOp(args) {
|
|
335
|
+
const st = getPrStatus();
|
|
336
|
+
const lines = [formatPrStatus(st)];
|
|
337
|
+
if (args.apply) {
|
|
338
|
+
const applied = applyPrStatus(st);
|
|
339
|
+
if (applied.length === 0) {
|
|
340
|
+
lines.push(`nothing to apply.`);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
for (const a of applied) {
|
|
344
|
+
lines.push(`applied ${a.memoryId.slice(0, 8)}: ${a.from} → ${a.to} (by ${a.by})`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return { title: "memory: pr-status", output: lines.join("\n") };
|
|
349
|
+
}
|
package/docs/CURATOR.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Curator Convention
|
|
2
|
+
|
|
3
|
+
The **curator** is the human who tends a project's shared memory. It is a
|
|
4
|
+
documented convention, not a permission system — anyone on the team can act
|
|
5
|
+
as curator; the tool records *who* did *what* (the audit trail), it doesn't
|
|
6
|
+
decide who is *allowed* to.
|
|
7
|
+
|
|
8
|
+
## What the curator does
|
|
9
|
+
|
|
10
|
+
1. **Triage proposals.** `open-memex propose` puts memories up for review.
|
|
11
|
+
The curator reads them, then `open-memex promote <id>` to approve or
|
|
12
|
+
`open-memex promote <id> --reject` to send back, with `--note` saying why.
|
|
13
|
+
2. **Resolve conflicts.** `open-memex resolve` lists file-level and semantic
|
|
14
|
+
conflicts. The curator merges or picks a winner — Core never silently
|
|
15
|
+
resolves a semantic conflict; both sides stay `active` until a human
|
|
16
|
+
decides.
|
|
17
|
+
3. **Keep the garden.** Deprecate what's stale (`open-memex status <id>
|
|
18
|
+
deprecated`), supersede what's been replaced, forget what's noise.
|
|
19
|
+
Shared memory rots without pruning.
|
|
20
|
+
4. **Watch the pipeline.** `open-memex sync-status` shows the outbox, review
|
|
21
|
+
states, and uncommitted files; `open-memex pr-status --apply` maps the
|
|
22
|
+
GitHub PR state back onto `review_state`.
|
|
23
|
+
|
|
24
|
+
## Admission bar
|
|
25
|
+
|
|
26
|
+
Approve a memory when it is:
|
|
27
|
+
|
|
28
|
+
- **True** — you believe it, or it cites something verifiable.
|
|
29
|
+
- **Durable** — it will still matter in a month. Chat logs are not memories.
|
|
30
|
+
- **Scoped right** — project knowledge in project scope; personal stuff stays
|
|
31
|
+
personal (personal memories are never the curator's business).
|
|
32
|
+
- **Well-typed** — `type` says what it IS (`decision`, `gotcha`, `lesson`…),
|
|
33
|
+
`tags` say what it's ABOUT.
|
|
34
|
+
|
|
35
|
+
Send back (don't silently fix) when it's vague, duplicated, or belongs in
|
|
36
|
+
`docs/` as formal documentation instead — memory is the fast-changing long
|
|
37
|
+
tail, `docs/` is the slow-changing core.
|
|
38
|
+
|
|
39
|
+
## What the curator does NOT do
|
|
40
|
+
|
|
41
|
+
- **Never rewrite someone else's memory in place.** Propose a superseding
|
|
42
|
+
memory instead — the chain (`supersedes` / `superseded_by`) is the audit
|
|
43
|
+
trail.
|
|
44
|
+
- **Never approve their own proposals silently in team mode.** That's what
|
|
45
|
+
`--local-approve` is for — solo projects only.
|
|
46
|
+
- **Never pull rank with the tool.** If the team disagrees with a call, the
|
|
47
|
+
disagreement itself is worth a memory.
|
|
48
|
+
|
|
49
|
+
## Cadence
|
|
50
|
+
|
|
51
|
+
There is no required cadence. A workable default: triage proposals at the
|
|
52
|
+
end of each work chunk (the same checkpoint where §3.5 distillation runs),
|
|
53
|
+
and do a pruning pass when `sync-status` starts feeling noisy.
|
|
54
|
+
|
|
55
|
+
## Solo mode
|
|
56
|
+
|
|
57
|
+
No team, no curator needed. `open-memex propose --local-approve` records
|
|
58
|
+
`approved_by: self` and skips the PR. You are the curator, the proposer,
|
|
59
|
+
and the gardener — the same hygiene rules apply, just faster.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# OpenMemex User Guide
|
|
2
|
+
|
|
3
|
+
> This document describes open-memex's **mental model**: where your memories live,
|
|
4
|
+
> how they flow, and who can see them. The in-repo directory (§2, §6 write path)
|
|
5
|
+
> and the propose → promote → resolve workflow (§5) are implemented on the
|
|
6
|
+
> `V2-dev-p2b` branch; features marked **2B** are still to be built;
|
|
7
|
+
> everything else is 0.3.0 behavior.
|
|
8
|
+
|
|
9
|
+
## In one sentence
|
|
10
|
+
|
|
11
|
+
open-memex is a local-first memory layer: **Markdown is the source of truth;
|
|
12
|
+
SQLite is just a rebuildable index.** Lose the index and you rebuild it.
|
|
13
|
+
Lose the Markdown and it's really gone.
|
|
14
|
+
|
|
15
|
+
## 1. Two scopes: personal vs project
|
|
16
|
+
|
|
17
|
+
Every memory belongs to a scope, which decides **how far it may travel**:
|
|
18
|
+
|
|
19
|
+
| | personal | project |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Holds | Facts about you: preferences, habits, cross-project info | Facts about the project: stack, conventions, decisions, gotchas |
|
|
22
|
+
| Lives | Only on your machine (see §2) | Local + eligible for team sharing (see §2, §6) |
|
|
23
|
+
| Rule | **Never leaves this machine** | Can be promoted to team knowledge |
|
|
24
|
+
|
|
25
|
+
Keywords route automatically (D18): "remember… / I think… / I like…" (I) → personal;
|
|
26
|
+
"we decided… / we think… / remember for us…" (we) → project.
|
|
27
|
+
|
|
28
|
+
## 2. Two homes: appdata vs the repo directory
|
|
29
|
+
|
|
30
|
+
| | appdata (the desk) | repo `.ai/open-memex/` (the shelf) |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Location | Windows `%APPDATA%/open-memex`, Linux `~/.local/share/open-memex` | Project root, default `.ai/open-memex/` (D23, configurable via `memoryDir`) |
|
|
33
|
+
| Holds | `memories/personal/` personal memories; project-scope **draft outbox**; `index.db` local index | Submitted project memories (`proposed` and up) |
|
|
34
|
+
| In git? | No | Yes — git is its courier |
|
|
35
|
+
| The index? | `index.db` is rebuildable, **never committed** | No index stored; rebuilt from Markdown on demand |
|
|
36
|
+
|
|
37
|
+
The private notebook (personal) never goes on the shelf. That's an iron rule, not a setting.
|
|
38
|
+
|
|
39
|
+
Project memories start life as **drafts in the appdata outbox** — git-invisible,
|
|
40
|
+
branch-independent. Only drafts you explicitly name move to the shelf, via
|
|
41
|
+
`open-memex submit` (§5). One stage, one home: after a successful submit the
|
|
42
|
+
outbox original is gone; before it, the repo knows nothing.
|
|
43
|
+
|
|
44
|
+
## 3. First-turn injection: how the 8 and 5 are chosen
|
|
45
|
+
|
|
46
|
+
Before the agent's first turn, open-memex injects an `[OPEN-MEMEX]` context block so it
|
|
47
|
+
doesn't need a search call just to get oriented. Defaults: **8 project + 5 personal**
|
|
48
|
+
memories (`maxProjectMemories` / `maxProfileItems`, tunable).
|
|
49
|
+
|
|
50
|
+
Selection has exactly one criterion: **most recently updated first**
|
|
51
|
+
(`ORDER BY updated_at DESC`), minus retracted/archived items, with superseded
|
|
52
|
+
versions resolved away. Not "the N most important" — "the N most recently touched".
|
|
53
|
+
What you just updated is most likely relevant to what you're doing now.
|
|
54
|
+
|
|
55
|
+
Token budget: each item is squeezed to one line (≤240 chars, ~60 tokens), so 8 items
|
|
56
|
+
cost ≈ 500 tokens worst case. Beyond that, diminishing returns — the long tail is what
|
|
57
|
+
`memory_search` is for; stuffing everything into the first turn buries the signal.
|
|
58
|
+
|
|
59
|
+
Raise it: `open-memex config set maxProjectMemories 12`
|
|
60
|
+
|
|
61
|
+
## 4. Later turns: when memory gets searched again
|
|
62
|
+
|
|
63
|
+
First-turn injection happens once. After that there is **no automatic re-search** —
|
|
64
|
+
MCP is request/response; the server cannot push. Whether `memory_search` gets called
|
|
65
|
+
is entirely the model's judgment, guided by two things:
|
|
66
|
+
|
|
67
|
+
- The injected block's footer: "Use the `memory_search` tool to look up more."
|
|
68
|
+
- The Copilot instructions written by `init`: "before asking the user about something
|
|
69
|
+
they may have told you before, call `memory_search` first — try a few keyword
|
|
70
|
+
variants before giving up."
|
|
71
|
+
|
|
72
|
+
So: when the conversation touches past decisions, preferences, or conventions, the
|
|
73
|
+
model *should* search first — but nothing enforces it. This is a known gap in the
|
|
74
|
+
current version; Phase 2C's native plugin hooks (e.g. Claude Code's
|
|
75
|
+
`UserPromptSubmit`) are meant to close it.
|
|
76
|
+
|
|
77
|
+
## 5. Promotion workflow: how a memory becomes team knowledge
|
|
78
|
+
|
|
79
|
+
A personal observation becomes team knowledge by exactly one road —
|
|
80
|
+
**explicit promotion, never automatic sync**:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
personal idea ──propose──▶ outbox draft ──submit──▶ proposed ──┬──promote──▶ approved ──promote──▶ published/shared
|
|
84
|
+
│ │
|
|
85
|
+
--reject --reject
|
|
86
|
+
│ │ (approval withdrawn
|
|
87
|
+
▼ │ before merge)
|
|
88
|
+
rejected ──resubmit──▶ proposed
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- `open-memex propose <id...> --to project`: **copies** one or more personal
|
|
92
|
+
memories into the **appdata outbox** as review candidates (`review_state:
|
|
93
|
+
draft`, each with its own new id, `derived_from` pointing back at the
|
|
94
|
+
personal original). **Copy, not move — the personal original stays.**
|
|
95
|
+
All-or-nothing: a bad id aborts the whole batch. Nothing touches the repo
|
|
96
|
+
yet — the outbox is git-invisible and branch-independent.
|
|
97
|
+
Solo devs can use `--local-approve` to self-approve.
|
|
98
|
+
- `open-memex sync-status`: shows **when the index was last synced** (and
|
|
99
|
+
what triggered it — session start, a request, a CLI run, a submit), the
|
|
100
|
+
outbox (pending sync), the repo review states
|
|
101
|
+
(`draft / proposed / approved / published / rejected`), and any uncommitted
|
|
102
|
+
repo memory files. Your agent calls this at session start, at meaningful
|
|
103
|
+
checkpoints, after you commit, and after memory actions — then asks which
|
|
104
|
+
drafts (if any) you want synced. You can also just say "sync memory".
|
|
105
|
+
- `open-memex submit <id...>`: moves **your named drafts** into
|
|
106
|
+
`<repo>/.ai/open-memex/` as `proposed`. It copies the files, flips
|
|
107
|
+
`review_state`, and makes a **local** git commit **on your current branch** —
|
|
108
|
+
it never creates a branch on its own (branch creation is your call, or your
|
|
109
|
+
agent's with your explicit approval via `--branch <name>`).
|
|
110
|
+
All-or-nothing, idempotent, crash-safe. It prints the `git push` +
|
|
111
|
+
`gh pr create` commands; if your agent already has your Yes for this sync,
|
|
112
|
+
it carries through push and PR itself. The PR base defaults to the current
|
|
113
|
+
branch; `--base` redirects to `main` or your integration branch.
|
|
114
|
+
Same id with different content on the branch **aborts** — a human decides,
|
|
115
|
+
never auto-overwrite.
|
|
116
|
+
- Keep the four jobs straight: **propose crosses the boundary**
|
|
117
|
+
(personal → project outbox, the only step that copies across);
|
|
118
|
+
**submit moves drafts into the repo** (outbox → `.ai/open-memex/`,
|
|
119
|
+
`draft → proposed`); **promote only flips the status label** on a file
|
|
120
|
+
already in `.ai/open-memex/` (`proposed → approved → published`) — it
|
|
121
|
+
never moves files between directories; **git does the transport**
|
|
122
|
+
(push, PR, merge).
|
|
123
|
+
- A standalone memory PR contains **only memory files, no code**, reviewed and
|
|
124
|
+
audited separately from code PRs. Reviewers check "is this true? is it safe
|
|
125
|
+
to share? any secrets?" — things a code PR's CI never checks. You create the
|
|
126
|
+
branch yourself when you want one (or your agent does, with your approval).
|
|
127
|
+
- "Request changes" needs no command: while the PR is open, the author edits
|
|
128
|
+
the same file (directly, or by asking their agent in chat), commits, and
|
|
129
|
+
pushes. The state stays `proposed`; the PR is the review mechanism.
|
|
130
|
+
- `open-memex promote <id>`: advances the memory one step up the ladder
|
|
131
|
+
(`proposed → approved → published`). `--reject --note "..."` rejects with a
|
|
132
|
+
reason (also allowed from `approved`, before merge — withdrawing approval).
|
|
133
|
+
Every transition is appended to the memory's `review_history` — who moved it,
|
|
134
|
+
when, from what to what, and why — so the review trail survives long after
|
|
135
|
+
the PR is closed.
|
|
136
|
+
After the PR merges, run `promote <id>` once more to mark it `published`
|
|
137
|
+
— or let `pr-status --apply` do it for you (below).
|
|
138
|
+
(Lifting project memories to org level is Phase 4.)
|
|
139
|
+
- **A rejection never deletes anything.** The file stays on your branch; what
|
|
140
|
+
happens next is the human's call:
|
|
141
|
+
1. **Accept**: close the PR and delete the branch — the file goes with it
|
|
142
|
+
(the local index cleans itself up on the next sync);
|
|
143
|
+
2. **Revise and resubmit**: edit the file, run
|
|
144
|
+
`open-memex promote <id> --resubmit`, commit, push — review continues on
|
|
145
|
+
the same PR;
|
|
146
|
+
3. **Keep as a record**: leave it; it stays visible with a `[rejected]` tag
|
|
147
|
+
and your note, so the team can see what was considered and why not.
|
|
148
|
+
- `open-memex pr-status [--apply]`: reads the current branch's GitHub PR
|
|
149
|
+
and maps its state onto each in-repo memory's review state — merged PR →
|
|
150
|
+
`published`, PR approval → `approved` (`approved_by` = the reviewer), review
|
|
151
|
+
"request changes" → a suggestion for you to act on (never auto-rejects).
|
|
152
|
+
Each memory keeps its own state: a human `rejected` is never overridden by
|
|
153
|
+
a PR signal. Report by default; `--apply` performs the mapped transitions
|
|
154
|
+
locally (no push). This is how a team review on GitHub closes the loop back
|
|
155
|
+
into the memory index without new review UI.
|
|
156
|
+
- `open-memex resolve [id]`: with no argument, lists conflicted memory files;
|
|
157
|
+
with an id, attempts a **field-level 3-way merge** of the YAML frontmatter
|
|
158
|
+
(`tags` union, `updated_at` takes latest, body merged when only one side
|
|
159
|
+
changed). Semantic conflicts — both sides changed the same field or the
|
|
160
|
+
body differently — are **reported, never auto-resolved**: the file is left
|
|
161
|
+
untouched for a human to decide.
|
|
162
|
+
- `list` / `search` show a `[draft]` / `[proposed]` / `[approved]` /
|
|
163
|
+
`[published]` / `[rejected]` tag next to project memories in review, and
|
|
164
|
+
retrieval ranks reviewed knowledge (`approved` / `published`) above
|
|
165
|
+
unreviewed outbox drafts — drafts stay findable but never pose as vetted.
|
|
166
|
+
|
|
167
|
+
## 6. Sync: git is the courier, not the brain
|
|
168
|
+
|
|
169
|
+
- **Write**: `memory_add` (project scope) → writes the **appdata outbox** and
|
|
170
|
+
updates the local index. **Never touches the repo, never auto-commits,
|
|
171
|
+
never auto-pushes.**
|
|
172
|
+
- **Submit** (explicit, your call): `open-memex submit <id...>` → local branch
|
|
173
|
+
+ local commit into `.ai/open-memex/`; push/PR are printed for you (or done
|
|
174
|
+
by your agent on your Yes).
|
|
175
|
+
- **Pull** **2B**: `open-memex pull` (always explicit, never automatic) → git fetch +
|
|
176
|
+
fast-forward → scans `.ai/open-memex/*.md` → merges into the local `index.db` by
|
|
177
|
+
file mtime. Retrieval always goes through SQLite, never walks git.
|
|
178
|
+
- **personal scope**: never syncs (§1 iron rule).
|
|
179
|
+
- **Projects without git**: keep working; project scope degrades to local-only with
|
|
180
|
+
a clear notice. Nothing breaks.
|
|
181
|
+
|
|
182
|
+
## 7. Security baseline
|
|
183
|
+
|
|
184
|
+
- Memory is **data** by default, never instructions — external content (pulled from
|
|
185
|
+
sync, promoted by others) passes an admission check before it lands; the
|
|
186
|
+
prompt-injection screen on shared files is heuristic, the real boundary is the
|
|
187
|
+
data/instruction separation.
|
|
188
|
+
- Every memory carries provenance: `source` + `confidence` + `via` + author.
|
|
189
|
+
Shared-scope writes go into an audit log.
|
|
190
|
+
- A pre-commit hook scans shared scopes for secrets and guards against history
|
|
191
|
+
rewrites.
|
|
192
|
+
- `<private>…</private>` sections are stripped at write time; detected secret
|
|
193
|
+
patterns are masked in place and the write proceeds.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
*Companion design record: `docs/V2-DESIGN.md` (decisions D1–D24).*
|