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/config.js
CHANGED
|
@@ -33,6 +33,8 @@ export const DEFAULT_CONFIG = {
|
|
|
33
33
|
// Add only your own extra patterns here.
|
|
34
34
|
redactPatterns: [],
|
|
35
35
|
logLevel: "info",
|
|
36
|
+
memoryDir: ".ai/open-memex",
|
|
37
|
+
sync: { autoPull: false },
|
|
36
38
|
};
|
|
37
39
|
function stripJsonComments(raw) {
|
|
38
40
|
// Line comments
|
|
@@ -63,12 +65,22 @@ function toNonNegInt(v) {
|
|
|
63
65
|
throw new Error("must be a non-negative integer");
|
|
64
66
|
return n;
|
|
65
67
|
}
|
|
68
|
+
function toRelativeDir(v) {
|
|
69
|
+
if (typeof v !== "string" || !v.trim())
|
|
70
|
+
throw new Error("must be a non-empty relative directory");
|
|
71
|
+
const t = v.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
72
|
+
if (!t || path.isAbsolute(t) || t.split("/").includes(".."))
|
|
73
|
+
throw new Error('must be a relative path without ".." (e.g. ".ai/open-memex")');
|
|
74
|
+
return t;
|
|
75
|
+
}
|
|
66
76
|
/** Keys users may change via `open-memex config set <key> <value>`, with validators. */
|
|
67
77
|
export const SETTABLE_KEYS = {
|
|
68
78
|
maxProjectMemories: toNonNegInt,
|
|
69
79
|
maxProfileItems: toNonNegInt,
|
|
70
80
|
injectOnFirstTurn: toBool,
|
|
71
81
|
keywordCaptureEnabled: toBool,
|
|
82
|
+
memoryDir: toRelativeDir,
|
|
83
|
+
"sync.autoPull": toBool,
|
|
72
84
|
logLevel: (v) => {
|
|
73
85
|
if (v !== "info" && v !== "debug")
|
|
74
86
|
throw new Error('must be "info" or "debug"');
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* distill-to-AGENTS.md assist (Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Turns distilled project memories (decisions, lessons, gotchas, howtos,
|
|
5
|
+
* constraints) into a proposed AGENTS.md snippet. ASSIST only: it prints
|
|
6
|
+
* (or writes with -o) a markdown section for the human to review and merge
|
|
7
|
+
* by hand — open-memex never rewrites your AGENTS.md on its own.
|
|
8
|
+
*/
|
|
9
|
+
import { db } from "./store/db.js";
|
|
10
|
+
const DEFAULT_TYPES = ["decision", "constraint", "lesson", "gotcha", "howto"];
|
|
11
|
+
export function distillAgentsMarkdown(opts) {
|
|
12
|
+
const types = opts.types?.length ? opts.types : DEFAULT_TYPES;
|
|
13
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 30, 200));
|
|
14
|
+
const sql = `
|
|
15
|
+
SELECT id, type, tags, content, updated_at FROM memories
|
|
16
|
+
WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})
|
|
17
|
+
AND type IN (${types.map(() => "?").join(",")})
|
|
18
|
+
AND status = 'active'
|
|
19
|
+
ORDER BY updated_at DESC
|
|
20
|
+
LIMIT ?`;
|
|
21
|
+
const rows = db()
|
|
22
|
+
.prepare(sql)
|
|
23
|
+
.all(...opts.scopeKeys, ...types, limit);
|
|
24
|
+
if (rows.length === 0)
|
|
25
|
+
return "";
|
|
26
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
27
|
+
const lines = [
|
|
28
|
+
`## Learned (distilled from open-memex, ${date})`,
|
|
29
|
+
``,
|
|
30
|
+
`<!-- Proposed by \`open-memex distill-agents\`. Review each line, keep`,
|
|
31
|
+
`what's true, delete the rest, then merge into this file by hand. -->`,
|
|
32
|
+
``,
|
|
33
|
+
];
|
|
34
|
+
// Group by type, preserving recency within each group.
|
|
35
|
+
const byType = new Map();
|
|
36
|
+
for (const r of rows) {
|
|
37
|
+
const g = byType.get(r.type) ?? [];
|
|
38
|
+
g.push(r);
|
|
39
|
+
byType.set(r.type, g);
|
|
40
|
+
}
|
|
41
|
+
for (const t of types) {
|
|
42
|
+
const g = byType.get(t);
|
|
43
|
+
if (!g?.length)
|
|
44
|
+
continue;
|
|
45
|
+
lines.push(`### ${t}`);
|
|
46
|
+
lines.push(``);
|
|
47
|
+
for (const r of g) {
|
|
48
|
+
const first = r.content.split("\n").map((l) => l.trim()).find((l) => l) ?? "";
|
|
49
|
+
const snippet = first.length > 160 ? first.slice(0, 160) + "…" : first;
|
|
50
|
+
lines.push(`- ${snippet} \`[${r.id.slice(0, 8)}]\``);
|
|
51
|
+
}
|
|
52
|
+
lines.push(``);
|
|
53
|
+
}
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -116,7 +116,7 @@ function mcpCheck() {
|
|
|
116
116
|
name,
|
|
117
117
|
ok: missing.length === 0,
|
|
118
118
|
detail: missing.length === 0
|
|
119
|
-
? `handshake OK,
|
|
119
|
+
? `handshake OK, ${names.length} tools listed (${names.join(", ")})`
|
|
120
120
|
: `missing tools: ${missing.join(", ")}`,
|
|
121
121
|
});
|
|
122
122
|
}
|
package/dist/export.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export / import: portable memory archives (§9, D40).
|
|
3
|
+
*
|
|
4
|
+
* `export` bundles selected memories (markdown source of truth + manifest)
|
|
5
|
+
* into a single .tar.gz for moving to another machine or another app.
|
|
6
|
+
* `import` restores a bundle: personal memories go to the personal dir,
|
|
7
|
+
* project memories are re-keyed to the current project and land in the
|
|
8
|
+
* outbox as drafts (submit moves them into the repo).
|
|
9
|
+
*
|
|
10
|
+
* D40: export excludes `visibility: private` by default; `--all` / `-a`
|
|
11
|
+
* includes everything — the full-migration escape hatch.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { db } from "./store/db.js";
|
|
18
|
+
import { readMemoryFile, writeMemoryFile, normalizeFrontmatter, } from "./store/markdown.js";
|
|
19
|
+
import { upsertFromFile } from "./store/sync.js";
|
|
20
|
+
import { contentHash } from "./store/lifecycle.js";
|
|
21
|
+
const EXPORT_FORMAT = "open-memex-export/1";
|
|
22
|
+
function fail(msg) {
|
|
23
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
24
|
+
}
|
|
25
|
+
function checkTar() {
|
|
26
|
+
try {
|
|
27
|
+
execFileSync("tar", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
fail("the `tar` command is required for export/import but was not found on PATH");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function exportMemories(opts) {
|
|
34
|
+
checkTar();
|
|
35
|
+
const includePrivate = opts.includePrivate ?? false;
|
|
36
|
+
let sql = `SELECT id, scope_key, scope, visibility, type, file_path FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})`;
|
|
37
|
+
const params = [...opts.scopeKeys];
|
|
38
|
+
if (!includePrivate)
|
|
39
|
+
sql += ` AND visibility != 'private'`;
|
|
40
|
+
if (opts.type) {
|
|
41
|
+
sql += ` AND type = ?`;
|
|
42
|
+
params.push(opts.type);
|
|
43
|
+
}
|
|
44
|
+
if (opts.tag) {
|
|
45
|
+
sql += ` AND (',' || tags || ',' LIKE ?)`;
|
|
46
|
+
params.push(`%,${opts.tag},%`);
|
|
47
|
+
}
|
|
48
|
+
sql += ` ORDER BY updated_at DESC`;
|
|
49
|
+
const rows = db().prepare(sql).all(...params);
|
|
50
|
+
const skippedPrivate = includePrivate
|
|
51
|
+
? 0
|
|
52
|
+
: db().prepare(`SELECT COUNT(*) AS n FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")}) AND visibility = 'private'`).get(...opts.scopeKeys).n;
|
|
53
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-export-"));
|
|
54
|
+
try {
|
|
55
|
+
const memDir = path.join(stage, "memories");
|
|
56
|
+
fs.mkdirSync(memDir, { recursive: true });
|
|
57
|
+
const manifestEntries = [];
|
|
58
|
+
let exported = 0;
|
|
59
|
+
for (const r of rows) {
|
|
60
|
+
const mf = readMemoryFile(r.file_path);
|
|
61
|
+
if (!mf)
|
|
62
|
+
continue; // stale index row — skip, don't fail the export
|
|
63
|
+
const rel = path.join(r.scope, `${r.id}.md`);
|
|
64
|
+
const dest = path.join(memDir, rel);
|
|
65
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
66
|
+
fs.copyFileSync(r.file_path, dest);
|
|
67
|
+
manifestEntries.push({ id: r.id, scope: r.scope, file: path.join("memories", rel) });
|
|
68
|
+
exported++;
|
|
69
|
+
}
|
|
70
|
+
const manifest = {
|
|
71
|
+
format: EXPORT_FORMAT,
|
|
72
|
+
exported_at: new Date().toISOString(),
|
|
73
|
+
open_memex_version: readPackageVersion(),
|
|
74
|
+
include_private: includePrivate,
|
|
75
|
+
filters: {
|
|
76
|
+
scope_keys: opts.scopeKeys,
|
|
77
|
+
type: opts.type ?? null,
|
|
78
|
+
tag: opts.tag ?? null,
|
|
79
|
+
},
|
|
80
|
+
memories: manifestEntries,
|
|
81
|
+
};
|
|
82
|
+
fs.writeFileSync(path.join(stage, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
|
|
83
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
|
|
84
|
+
const outFile = opts.outFile ?? path.resolve(`open-memex-export-${stamp}.tar.gz`);
|
|
85
|
+
execFileSync("tar", ["-czf", outFile, "-C", stage, "manifest.json", "memories"], {
|
|
86
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
87
|
+
});
|
|
88
|
+
return { file: outFile, exported, skippedPrivate, includePrivate };
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function readPackageVersion() {
|
|
95
|
+
try {
|
|
96
|
+
const here = new URL(import.meta.url);
|
|
97
|
+
const pkg = path.join(path.dirname(here.pathname), "..", "package.json");
|
|
98
|
+
return JSON.parse(fs.readFileSync(pkg, "utf8")).version;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return "unknown";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function importBundle(bundlePath, opts) {
|
|
105
|
+
checkTar();
|
|
106
|
+
if (!fs.existsSync(bundlePath))
|
|
107
|
+
fail(`bundle not found: ${bundlePath}`);
|
|
108
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-import-"));
|
|
109
|
+
try {
|
|
110
|
+
execFileSync("tar", ["-xzf", path.resolve(bundlePath), "-C", stage], {
|
|
111
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
112
|
+
});
|
|
113
|
+
const manifestPath = path.join(stage, "manifest.json");
|
|
114
|
+
if (!fs.existsSync(manifestPath))
|
|
115
|
+
fail("not an open-memex export bundle (manifest.json missing)");
|
|
116
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
117
|
+
if (manifest.format !== EXPORT_FORMAT) {
|
|
118
|
+
fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
|
|
119
|
+
}
|
|
120
|
+
const result = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
|
|
121
|
+
const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
|
|
122
|
+
for (const entry of manifest.memories ?? []) {
|
|
123
|
+
const src = path.join(stage, entry.file);
|
|
124
|
+
const mf = readMemoryFile(src);
|
|
125
|
+
if (!mf)
|
|
126
|
+
continue;
|
|
127
|
+
const fm = normalizeFrontmatter({ ...mf.fm });
|
|
128
|
+
// Re-key to this machine: personal stays personal; project memories
|
|
129
|
+
// adopt the current project's scope key (keys embed a path hash).
|
|
130
|
+
if (fm.scope === "project")
|
|
131
|
+
fm.scope_key = opts.projectScopeKey;
|
|
132
|
+
else if (fm.scope === "personal")
|
|
133
|
+
fm.scope_key = "personal";
|
|
134
|
+
const existing = existingStmt.get(fm.id);
|
|
135
|
+
if (existing) {
|
|
136
|
+
if (existing.content_hash === contentHash(mf.body))
|
|
137
|
+
result.skippedIdentical++;
|
|
138
|
+
else
|
|
139
|
+
result.skippedConflict.push({ id: fm.id, file: entry.file });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (opts.dryRun) {
|
|
143
|
+
result.imported++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const { filePath } = writeMemoryFile(fm, mf.body);
|
|
147
|
+
const written = readMemoryFile(filePath);
|
|
148
|
+
if (written)
|
|
149
|
+
upsertFromFile(written);
|
|
150
|
+
result.imported++;
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/github.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2B/D32: GitHub PR ↔ review_state mapping.
|
|
3
|
+
*
|
|
4
|
+
* Memories ride into review inside a branch + PR (dedicated memory PR or
|
|
5
|
+
* together with code). The PR's lifecycle on GitHub is the team's review
|
|
6
|
+
* signal; this module reads it back and maps it onto each memory's own
|
|
7
|
+
* review_state:
|
|
8
|
+
*
|
|
9
|
+
* PR merged → proposed/approved → published
|
|
10
|
+
* PR reviewDecision == APPROVED → proposed → approved (approved_by = reviewer login)
|
|
11
|
+
* PR reviewDecision == CHANGES_REQUESTED → suggestion only, printed for the human
|
|
12
|
+
*
|
|
13
|
+
* Per-memory independence: every memory on the branch keeps its own state.
|
|
14
|
+
* A locally `rejected` memory is never flipped by a PR approval, and a
|
|
15
|
+
* changes-requested review never auto-rejects — the human runs `promote`.
|
|
16
|
+
*
|
|
17
|
+
* Report by default; `--apply` performs the mapped transitions locally
|
|
18
|
+
* (file rewrite + index, never a push — inside the D27 local-git line).
|
|
19
|
+
* Needs the GitHub CLI (`gh`) authenticated on the user's machine.
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import { loadConfig } from "./config.js";
|
|
24
|
+
import { projectRoot } from "./paths.js";
|
|
25
|
+
import { parse as parseMemory } from "./store/markdown.js";
|
|
26
|
+
import { iterInRepoMemoryFiles } from "./store/markdown.js";
|
|
27
|
+
import { promoteMemory, currentAuthor } from "./review.js";
|
|
28
|
+
function fail(msg) {
|
|
29
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
30
|
+
}
|
|
31
|
+
function sh(cmd, args, cwd) {
|
|
32
|
+
try {
|
|
33
|
+
return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
const err = e;
|
|
37
|
+
const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
|
|
38
|
+
fail(`${cmd} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Read the PR for a branch (default: current branch) via the GitHub CLI. */
|
|
42
|
+
export function getPrInfo(branch) {
|
|
43
|
+
const root = projectRoot();
|
|
44
|
+
// `gh` must exist and be authenticated on the user's machine.
|
|
45
|
+
try {
|
|
46
|
+
execFileSync("gh", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
fail("the GitHub CLI (`gh`) is not installed — install it and run `gh auth login` first.");
|
|
50
|
+
}
|
|
51
|
+
const ref = branch ?? sh("git", ["branch", "--show-current"], root);
|
|
52
|
+
if (!ref)
|
|
53
|
+
fail("not on a branch — check out the memory branch first.");
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = sh("gh", ["pr", "view", ref, "--json",
|
|
57
|
+
"number,url,state,mergedAt,reviewDecision,baseRefName,headRefName,title,reviews"], root);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null; // no PR for this branch yet
|
|
61
|
+
}
|
|
62
|
+
let j;
|
|
63
|
+
try {
|
|
64
|
+
j = JSON.parse(raw);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
fail(`could not parse \`gh pr view\` output for branch ${ref}.`);
|
|
68
|
+
}
|
|
69
|
+
const approvedBy = [];
|
|
70
|
+
const changesRequestedBy = [];
|
|
71
|
+
// Latest review per author wins.
|
|
72
|
+
const latest = new Map();
|
|
73
|
+
for (const r of j.reviews ?? []) {
|
|
74
|
+
const login = r.author?.login;
|
|
75
|
+
if (!login)
|
|
76
|
+
continue;
|
|
77
|
+
const prev = latest.get(login);
|
|
78
|
+
if (!prev || r.submittedAt >= prev.at)
|
|
79
|
+
latest.set(login, { state: r.state, at: r.submittedAt });
|
|
80
|
+
}
|
|
81
|
+
for (const [login, r] of latest) {
|
|
82
|
+
if (r.state === "APPROVED")
|
|
83
|
+
approvedBy.push(login);
|
|
84
|
+
else if (r.state === "CHANGES_REQUESTED")
|
|
85
|
+
changesRequestedBy.push(login);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
number: j.number,
|
|
89
|
+
url: j.url,
|
|
90
|
+
state: j.state === "OPEN" ? "open" : "closed",
|
|
91
|
+
merged: !!j.mergedAt,
|
|
92
|
+
mergedAt: j.mergedAt,
|
|
93
|
+
reviewDecision: j.reviewDecision ?? null,
|
|
94
|
+
approvedBy,
|
|
95
|
+
changesRequestedBy,
|
|
96
|
+
base: j.baseRefName,
|
|
97
|
+
head: j.headRefName,
|
|
98
|
+
title: j.title,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Map the branch PR's state onto each in-repo memory's review_state. */
|
|
102
|
+
export function getPrStatus(branch) {
|
|
103
|
+
const root = projectRoot();
|
|
104
|
+
const cfg = loadConfig();
|
|
105
|
+
const current = branch ?? sh("git", ["branch", "--show-current"], root);
|
|
106
|
+
const pr = getPrInfo(current);
|
|
107
|
+
const mappings = [];
|
|
108
|
+
for (const fp of iterInRepoMemoryFiles(root, cfg.memoryDir)) {
|
|
109
|
+
const raw = fs.readFileSync(fp, "utf8");
|
|
110
|
+
const parsed = parseMemory(raw);
|
|
111
|
+
if (!parsed)
|
|
112
|
+
continue;
|
|
113
|
+
const from = parsed.fm.review_state ?? "draft";
|
|
114
|
+
const id = parsed.fm.id;
|
|
115
|
+
if (!pr) {
|
|
116
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `no PR for branch ${current} yet`, approvedBy: null });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (from === "published") {
|
|
120
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: "already published", approvedBy: null });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (from === "rejected") {
|
|
124
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `stays rejected — a PR ${pr.merged ? "merge" : "approval"} never overrides a human rejection`, approvedBy: null });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (pr.merged) {
|
|
128
|
+
if (from === "proposed" || from === "approved") {
|
|
129
|
+
mappings.push({ memoryId: id, from, suggestedTo: "published", reason: `PR #${pr.number} merged`, approvedBy: null });
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `PR #${pr.number} merged, but memory is "${from}"`, approvedBy: null });
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (pr.reviewDecision === "APPROVED" && from === "proposed") {
|
|
137
|
+
const by = pr.approvedBy[0] ?? null;
|
|
138
|
+
mappings.push({
|
|
139
|
+
memoryId: id, from, suggestedTo: "approved",
|
|
140
|
+
reason: `PR #${pr.number} approved${by ? ` by @${by}` : ""}`, approvedBy: by,
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (pr.reviewDecision === "CHANGES_REQUESTED") {
|
|
145
|
+
const by = pr.changesRequestedBy[0] ?? null;
|
|
146
|
+
mappings.push({
|
|
147
|
+
memoryId: id, from, suggestedTo: null,
|
|
148
|
+
reason: `PR #${pr.number} has changes requested${by ? ` by @${by}` : ""} — run \`open-memex promote ${id} --reject --note "..."\` yourself if you agree`,
|
|
149
|
+
approvedBy: null,
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
mappings.push({
|
|
154
|
+
memoryId: id, from, suggestedTo: null,
|
|
155
|
+
reason: pr.state === "open" ? `PR #${pr.number} still awaiting review` : `PR #${pr.number} is closed without merge`,
|
|
156
|
+
approvedBy: null,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return { branch: current, pr, mappings };
|
|
160
|
+
}
|
|
161
|
+
/** Apply the mapped transitions locally (no push — D27). Advances step by
|
|
162
|
+
* step through the ladder, so a merged PR moves proposed → approved →
|
|
163
|
+
* published with each step in the audit trail. */
|
|
164
|
+
export function applyPrStatus(st) {
|
|
165
|
+
const out = [];
|
|
166
|
+
const actor = currentAuthor();
|
|
167
|
+
const order = ["draft", "proposed", "approved", "published"];
|
|
168
|
+
for (const m of st.mappings) {
|
|
169
|
+
if (!m.suggestedTo || order.indexOf(m.from) >= order.indexOf(m.suggestedTo))
|
|
170
|
+
continue;
|
|
171
|
+
const by = m.approvedBy ?? actor;
|
|
172
|
+
const note = m.suggestedTo === "published" && st.pr
|
|
173
|
+
? `PR #${st.pr.number} merged`
|
|
174
|
+
: m.reason;
|
|
175
|
+
let cur = m.from;
|
|
176
|
+
while (order.indexOf(cur) < order.indexOf(m.suggestedTo)) {
|
|
177
|
+
const r = promoteMemory(m.memoryId, { by, note });
|
|
178
|
+
cur = r.to;
|
|
179
|
+
out.push({ memoryId: m.memoryId, from: r.from, to: r.to, by });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
export function formatPrStatus(st) {
|
|
185
|
+
const lines = [];
|
|
186
|
+
lines.push(`branch: ${st.branch}`);
|
|
187
|
+
if (!st.pr) {
|
|
188
|
+
lines.push(`PR: none yet — push the branch and open one, then re-run.`);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const p = st.pr;
|
|
192
|
+
lines.push(`PR #${p.number}: ${p.title}`);
|
|
193
|
+
lines.push(` ${p.url}`);
|
|
194
|
+
lines.push(` state: ${p.merged ? `merged${p.mergedAt ? ` at ${p.mergedAt}` : ""}` : p.state}` +
|
|
195
|
+
(p.reviewDecision ? `, reviews: ${p.reviewDecision}` : ""));
|
|
196
|
+
if (p.approvedBy.length > 0)
|
|
197
|
+
lines.push(` approved by: ${p.approvedBy.map((l) => "@" + l).join(", ")}`);
|
|
198
|
+
if (p.changesRequestedBy.length > 0)
|
|
199
|
+
lines.push(` changes requested by: ${p.changesRequestedBy.map((l) => "@" + l).join(", ")}`);
|
|
200
|
+
}
|
|
201
|
+
if (st.mappings.length === 0) {
|
|
202
|
+
lines.push(`no in-repo memories on this branch.`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
lines.push(`memories (${st.mappings.length}):`);
|
|
206
|
+
for (const m of st.mappings) {
|
|
207
|
+
const arrow = m.suggestedTo ? ` → [${m.suggestedTo}]` : "";
|
|
208
|
+
lines.push(` ${m.memoryId.slice(0, 8)} [${m.from}]${arrow} ${m.reason}`);
|
|
209
|
+
}
|
|
210
|
+
if (st.mappings.some((m) => m.suggestedTo)) {
|
|
211
|
+
lines.push(`run with --apply to perform the mapped transitions locally (no push).`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return lines.join("\n");
|
|
215
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -83,6 +83,12 @@ const plugin = async ({ worktree, directory }) => {
|
|
|
83
83
|
updated_at: rfc,
|
|
84
84
|
supersedes: null,
|
|
85
85
|
superseded_by: null,
|
|
86
|
+
review_state: "draft",
|
|
87
|
+
proposed_by: null,
|
|
88
|
+
approved_by: null,
|
|
89
|
+
derived_from: null,
|
|
90
|
+
review_note: null,
|
|
91
|
+
review_history: [],
|
|
86
92
|
};
|
|
87
93
|
try {
|
|
88
94
|
const { filePath } = writeMemoryFile(fm, content);
|
package/dist/init.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { execFileSync } from "node:child_process";
|
|
7
6
|
import { createInterface } from "node:readline/promises";
|
|
8
7
|
import { DEFAULT_CONFIG, saveConfig } from "./config.js";
|
|
8
|
+
import { projectRoot } from "./paths.js";
|
|
9
9
|
const MARKER = "<!-- open-memex -->";
|
|
10
10
|
/** npm dist-tag carrying the 0.3.x preview line. */
|
|
11
11
|
const ALPHA_TAG = "open-memex@alpha";
|
|
@@ -39,34 +39,59 @@ const INSTRUCTIONS = `${MARKER}
|
|
|
39
39
|
> Applies only when the \`open-memex\` MCP server is available in this session
|
|
40
40
|
> (the \`memory_*\` tools exist). Otherwise ignore this section.
|
|
41
41
|
|
|
42
|
-
You have a local memory MCP server (\`open-memex\`) with
|
|
43
|
-
\`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget
|
|
42
|
+
You have a local memory MCP server (\`open-memex\`) with eleven tools:
|
|
43
|
+
\`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`,
|
|
44
|
+
\`memory_status\`, \`memory_submit\`, \`memory_propose\`, \`memory_promote\`, \`memory_resolve\`,
|
|
45
|
+
\`memory_pr_status\`.
|
|
44
46
|
|
|
45
47
|
- BE PROACTIVE. When the user shares something worth remembering across sessions
|
|
46
48
|
(a decision, a preference, a project convention, a fix and its cause), call
|
|
47
49
|
\`memory_add\` without being asked. Keep each memory to one self-contained statement.
|
|
50
|
+
- At checkpoints (session start, end of a work chunk, after the user commits, after
|
|
51
|
+
any memory_* action), DISTILL the session: propose 1–3 short memories capturing the
|
|
52
|
+
useful conclusion — what was learned or decided, how an issue was resolved, what to
|
|
53
|
+
avoid, where the authoritative doc lives — not the raw transcript. Save NOTHING the
|
|
54
|
+
user did not approve; on approval call \`memory_add\` with source "inference" at the
|
|
55
|
+
confirmed scope. If the knowledge already lives in project docs, save a \`reference\`
|
|
56
|
+
memory pointing at the doc instead of copying it. Long-form notes are fine ONLY when
|
|
57
|
+
the user explicitly asks to save one.
|
|
48
58
|
- Before asking the user about past decisions, conventions, or preferences they may
|
|
49
59
|
have told you before, call \`memory_search\` first — try a few keyword variants
|
|
50
60
|
(including the user's own language) when the first search comes up empty.
|
|
51
61
|
- Memories default to this project's scope; use the \`personal\` scope for facts about
|
|
52
62
|
the user that hold across all projects. When a saved fact becomes outdated, call
|
|
53
63
|
\`memory_supersede\` instead of adding a duplicate.
|
|
64
|
+
|
|
65
|
+
## Syncing project memories for review (D26)
|
|
66
|
+
|
|
67
|
+
Project memories you save land in a local outbox first — they are NOT in git yet.
|
|
68
|
+
Syncing them into the repo for review is an explicit, user-approved step:
|
|
69
|
+
|
|
70
|
+
- At session start, when you finish a meaningful chunk of work, after the user
|
|
71
|
+
commits (git commit), and after any memory_* action completes, call
|
|
72
|
+
\`memory_status\`. If the outbox has drafts, summarize them (one line each) and ask
|
|
73
|
+
the user which ones to sync. Sync NOTHING the user did not name.
|
|
74
|
+
- When the user says "sync memory" (or "同步记忆"), treat it as a request to run
|
|
75
|
+
the sync flow above: call \`memory_status\`, summarize the outbox drafts, and ask
|
|
76
|
+
which ones to sync.
|
|
77
|
+
- When the user approves, call \`memory_submit\` with the approved ids. It copies
|
|
78
|
+
the drafts into the repo as \`proposed\`, commits locally on the CURRENT branch,
|
|
79
|
+
and prints the push + PR commands. It NEVER creates a branch on its own.
|
|
80
|
+
- After the submit, ask ONE follow-up: "want me to create a branch + push +
|
|
81
|
+
open the PR, or will you handle it yourself?" A "yes, you do it" answer covers
|
|
82
|
+
the whole chain — branch creation, push, PR creation — do NOT re-ask at each
|
|
83
|
+
step. If the user says they will do it themselves, hand them the printed
|
|
84
|
+
push/PR commands and do nothing. NEVER create branches, push, or open PRs
|
|
85
|
+
without their explicit approval.
|
|
86
|
+
- Base branch for the memory PR defaults to the branch you are on; the user may
|
|
87
|
+
redirect it to the integration branch (main) for branch-independent knowledge.
|
|
88
|
+
- If anything conflicts (same id with different content, push rejected), STOP and
|
|
89
|
+
let the user judge — never overwrite.
|
|
90
|
+
- After the PR merges, call \`memory_pr_status\` (with \`apply\` when the user
|
|
91
|
+
approves) to map the PR's review state back onto each memory — merged means
|
|
92
|
+
\`published\`, an approval means \`approved\` (credited to the reviewer).
|
|
93
|
+
- \`personal\` scope memories NEVER leave the machine.
|
|
54
94
|
`;
|
|
55
|
-
/** Project root: git top-level, falling back to cwd. */
|
|
56
|
-
function projectRoot() {
|
|
57
|
-
try {
|
|
58
|
-
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
59
|
-
encoding: "utf8",
|
|
60
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
61
|
-
}).trim();
|
|
62
|
-
if (top)
|
|
63
|
-
return top;
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
/* not a git repo — use cwd */
|
|
67
|
-
}
|
|
68
|
-
return process.cwd();
|
|
69
|
-
}
|
|
70
95
|
function writeMcpJson(root, client, force) {
|
|
71
96
|
if (client === "opencode")
|
|
72
97
|
return writeOpencodeMcpJson(root, force);
|