open-memex 0.3.0 → 0.4.0-alpha.1
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 +11 -1
- package/README.md +67 -6
- package/README.zh-CN.md +58 -6
- package/dist/cli.js +214 -11
- package/dist/config.js +10 -0
- package/dist/github.js +215 -0
- package/dist/index.js +6 -0
- package/dist/init.js +30 -18
- package/dist/mcp.js +44 -8
- package/dist/paths.js +31 -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 +49 -8
- package/dist/store/sync.js +74 -6
- package/dist/submit.js +339 -0
- package/dist/tools/ops.js +163 -6
- package/docs/USER-GUIDE.md +195 -0
- package/docs/USER-GUIDE.zh-CN.md +160 -0
- package/docs/V2-DESIGN.md +108 -6
- package/package.json +1 -1
- package/scripts/smoke-mcp.ts +2 -2
- package/src/cli.ts +204 -12
- package/src/config.ts +16 -0
- package/src/github.ts +257 -0
- package/src/index.ts +6 -0
- package/src/init.ts +30 -17
- package/src/mcp.ts +86 -7
- package/src/paths.ts +32 -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 +83 -8
- package/src/store/sync.ts +85 -4
- package/src/submit.ts +411 -0
- package/src/tools/ops.ts +192 -7
package/src/cli.ts
CHANGED
|
@@ -6,11 +6,13 @@ import { syncScope, upsertFromFile, deleteFromIndex } from "./store/sync.ts";
|
|
|
6
6
|
import { migrateScope, scopeHasFiles, type ConflictStrategy } from "./store/migrate.ts";
|
|
7
7
|
import { migrateV2 } from "./store/v2migrate.ts";
|
|
8
8
|
import { findDuplicates, supersede, setStatus } from "./store/lifecycle.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.ts";
|
|
10
|
+
import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.ts";
|
|
11
|
+
import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.ts";
|
|
12
|
+
import { search, list, hitStateLabel } from "./retrieve/search.ts";
|
|
10
13
|
import {
|
|
11
14
|
writeMemoryFile,
|
|
12
15
|
readMemoryFile,
|
|
13
|
-
deleteMemoryFile,
|
|
14
16
|
ulid,
|
|
15
17
|
msToRfc3339,
|
|
16
18
|
type Frontmatter,
|
|
@@ -34,6 +36,12 @@ Usage:
|
|
|
34
36
|
open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
|
|
35
37
|
open-memex status <id> active|deprecated|retracted|archived
|
|
36
38
|
open-memex forget <id>
|
|
39
|
+
open-memex propose <id...> --to project [--local-approve]
|
|
40
|
+
open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
|
|
41
|
+
open-memex resolve [id-or-path]
|
|
42
|
+
open-memex sync-status
|
|
43
|
+
open-memex submit <id...> [--onto <branch>] [--base <branch>]
|
|
44
|
+
open-memex pr-status [--apply]
|
|
37
45
|
open-memex reindex
|
|
38
46
|
open-memex scopes
|
|
39
47
|
open-memex migrate [--from <key>] [--to <key>]
|
|
@@ -344,8 +352,8 @@ async function main() {
|
|
|
344
352
|
}
|
|
345
353
|
|
|
346
354
|
if (cmd === "reindex") {
|
|
347
|
-
const a = syncScope(project.key);
|
|
348
|
-
const b = syncScope(PERSONAL_SCOPE.key);
|
|
355
|
+
const a = syncScope(project.key, "cli");
|
|
356
|
+
const b = syncScope(PERSONAL_SCOPE.key, "cli");
|
|
349
357
|
console.log(
|
|
350
358
|
`reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`,
|
|
351
359
|
);
|
|
@@ -355,7 +363,7 @@ async function main() {
|
|
|
355
363
|
if (cmd === "list") {
|
|
356
364
|
const flags = parseFlags(rest);
|
|
357
365
|
const s = resolveCliScope(flags, project);
|
|
358
|
-
syncScope(s.key);
|
|
366
|
+
syncScope(s.key, "cli");
|
|
359
367
|
const hits = list(s.key, {
|
|
360
368
|
type: flags.type,
|
|
361
369
|
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
@@ -366,7 +374,8 @@ async function main() {
|
|
|
366
374
|
}
|
|
367
375
|
for (const h of hits) {
|
|
368
376
|
const dep = h.status === "deprecated" ? " [deprecated]" : "";
|
|
369
|
-
|
|
377
|
+
const rev = hitStateLabel(h);
|
|
378
|
+
console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
370
379
|
}
|
|
371
380
|
return;
|
|
372
381
|
}
|
|
@@ -375,8 +384,8 @@ async function main() {
|
|
|
375
384
|
const query = rest[0];
|
|
376
385
|
if (!query || query.startsWith("--")) usage();
|
|
377
386
|
const flags = parseFlags(rest.slice(1));
|
|
378
|
-
syncScope(project.key);
|
|
379
|
-
syncScope(PERSONAL_SCOPE.key);
|
|
387
|
+
syncScope(project.key, "cli");
|
|
388
|
+
syncScope(PERSONAL_SCOPE.key, "cli");
|
|
380
389
|
const keys =
|
|
381
390
|
flags.scope === "personal" || flags.scope === "user"
|
|
382
391
|
? [PERSONAL_SCOPE.key]
|
|
@@ -395,7 +404,8 @@ async function main() {
|
|
|
395
404
|
for (const h of hits) {
|
|
396
405
|
const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
|
|
397
406
|
const dep = h.status === "deprecated" ? " [deprecated]" : "";
|
|
398
|
-
|
|
407
|
+
const rev = hitStateLabel(h);
|
|
408
|
+
console.log(`[${tag}/${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
399
409
|
}
|
|
400
410
|
return;
|
|
401
411
|
}
|
|
@@ -438,6 +448,12 @@ async function main() {
|
|
|
438
448
|
updated_at: rfc,
|
|
439
449
|
supersedes: null,
|
|
440
450
|
superseded_by: null,
|
|
451
|
+
review_state: "draft",
|
|
452
|
+
proposed_by: null,
|
|
453
|
+
approved_by: null,
|
|
454
|
+
derived_from: null,
|
|
455
|
+
review_note: null,
|
|
456
|
+
review_history: [],
|
|
441
457
|
};
|
|
442
458
|
const { filePath } = writeMemoryFile(fm, red);
|
|
443
459
|
const mf = readMemoryFile(filePath);
|
|
@@ -504,19 +520,195 @@ async function main() {
|
|
|
504
520
|
if (cmd === "forget") {
|
|
505
521
|
const id = rest[0];
|
|
506
522
|
if (!id) usage();
|
|
507
|
-
const row = db().prepare(`SELECT
|
|
508
|
-
| {
|
|
523
|
+
const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
|
|
524
|
+
| { file_path: string }
|
|
509
525
|
| undefined;
|
|
510
526
|
if (!row) {
|
|
511
527
|
console.error("not found");
|
|
512
528
|
process.exit(1);
|
|
513
529
|
}
|
|
514
|
-
|
|
530
|
+
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
531
|
+
if (row.file_path) {
|
|
532
|
+
try {
|
|
533
|
+
fs.unlinkSync(row.file_path);
|
|
534
|
+
} catch {
|
|
535
|
+
/* already gone */
|
|
536
|
+
}
|
|
537
|
+
}
|
|
515
538
|
deleteFromIndex(id);
|
|
516
539
|
console.log(`deleted ${id}`);
|
|
517
540
|
return;
|
|
518
541
|
}
|
|
519
542
|
|
|
543
|
+
/** Positional args with flags (and their values) skipped. */
|
|
544
|
+
function positionalArgs(argv: string[]): string[] {
|
|
545
|
+
const out: string[] = [];
|
|
546
|
+
for (let i = 0; i < argv.length; i++) {
|
|
547
|
+
const a = argv[i]!;
|
|
548
|
+
if (a.startsWith("--")) {
|
|
549
|
+
const val = argv[i + 1];
|
|
550
|
+
if (val !== undefined && !val.startsWith("--")) i++; // skip the flag's value
|
|
551
|
+
} else {
|
|
552
|
+
out.push(a);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (cmd === "propose") {
|
|
559
|
+
const flags = parseFlags(rest);
|
|
560
|
+
const ids = positionalArgs(rest);
|
|
561
|
+
if (ids.length === 0) usage();
|
|
562
|
+
const to = flags.to ?? "project";
|
|
563
|
+
if (to !== "project") {
|
|
564
|
+
console.error(`propose --to "${to}" is not supported yet (org sharing is Phase 4).`);
|
|
565
|
+
process.exit(2);
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const batch = proposeMemories(ids, { localApprove: flags["local-approve"] === "true" });
|
|
569
|
+
for (const { sourceId, result: r } of batch) {
|
|
570
|
+
console.log(`proposed ${sourceId} → ${r.id} [${r.reviewState}]`);
|
|
571
|
+
}
|
|
572
|
+
console.log(`Next: these are drafts in the outbox (appdata) — nothing is in git yet.`);
|
|
573
|
+
console.log(` open-memex sync-status`);
|
|
574
|
+
console.log(` open-memex submit ${batch.map(({ result: r }) => r.id).join(" ")} # new branch, local commit; prints push + PR commands`);
|
|
575
|
+
} catch (e) {
|
|
576
|
+
console.error(`propose failed: ${(e as Error).message}`);
|
|
577
|
+
process.exit(2);
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (cmd === "promote") {
|
|
583
|
+
const flags = parseFlags(rest);
|
|
584
|
+
const id = rest.find((a) => !a.startsWith("--"));
|
|
585
|
+
if (!id) usage();
|
|
586
|
+
try {
|
|
587
|
+
const r = promoteMemory(id, {
|
|
588
|
+
reject: flags.reject === "true",
|
|
589
|
+
resubmit: flags.resubmit === "true",
|
|
590
|
+
note: flags.note,
|
|
591
|
+
by: flags.by,
|
|
592
|
+
});
|
|
593
|
+
console.log(`promote ${r.id}: ${r.from} → ${r.to}`);
|
|
594
|
+
if (r.to === "published") {
|
|
595
|
+
console.log(` merged to the shared branch? Teammates pick it up with an explicit pull.`);
|
|
596
|
+
}
|
|
597
|
+
if (r.to === "rejected") {
|
|
598
|
+
console.log(` not deleted — the file stays on your branch. Your call:`);
|
|
599
|
+
console.log(` accept: close the PR and delete the branch;`);
|
|
600
|
+
console.log(` revise: edit the file, then \`open-memex promote ${r.id} --resubmit\`;`);
|
|
601
|
+
console.log(` keep: leave it as a [rejected] record.`);
|
|
602
|
+
}
|
|
603
|
+
if (r.history.length > 0) {
|
|
604
|
+
console.log(` history (${r.history.length}):`);
|
|
605
|
+
for (const h of formatReviewHistory(r.history)) console.log(h);
|
|
606
|
+
}
|
|
607
|
+
} catch (e) {
|
|
608
|
+
console.error(`promote failed: ${(e as Error).message}`);
|
|
609
|
+
process.exit(2);
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (cmd === "resolve") {
|
|
615
|
+
const target = rest.find((a) => !a.startsWith("--"));
|
|
616
|
+
try {
|
|
617
|
+
if (!target) {
|
|
618
|
+
const conflicts = listConflicts();
|
|
619
|
+
if (conflicts.length === 0) {
|
|
620
|
+
console.log("(no conflicted memory files)");
|
|
621
|
+
} else {
|
|
622
|
+
for (const c of conflicts) console.log(`conflicted: ${c.id} ${c.filePath}`);
|
|
623
|
+
console.log(`\nRun \`open-memex resolve <id>\` to attempt a field-level 3-way merge.`);
|
|
624
|
+
}
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const outcome = resolveConflict(target);
|
|
628
|
+
if (!outcome.ok) {
|
|
629
|
+
console.error(`cannot auto-resolve ${path.basename(outcome.filePath)} — semantic conflicts need a human:`);
|
|
630
|
+
for (const c of outcome.conflicts) {
|
|
631
|
+
console.error(` ${c.field}:`);
|
|
632
|
+
console.error(` base: ${c.base}`);
|
|
633
|
+
console.error(` ours: ${c.ours}`);
|
|
634
|
+
console.error(` theirs: ${c.theirs}`);
|
|
635
|
+
}
|
|
636
|
+
console.error(`Edit the file manually, then \`git add\` it. Nothing was written.`);
|
|
637
|
+
process.exit(3);
|
|
638
|
+
}
|
|
639
|
+
console.log(`resolved ${path.basename(outcome.filePath)}`);
|
|
640
|
+
if (outcome.autoMerged.length > 0)
|
|
641
|
+
console.log(` auto-merged: ${outcome.autoMerged.join(", ")}`);
|
|
642
|
+
console.log(` next: git add ${path.relative(process.cwd(), outcome.filePath)}`);
|
|
643
|
+
} catch (e) {
|
|
644
|
+
console.error(`resolve failed: ${(e as Error).message}`);
|
|
645
|
+
process.exit(2);
|
|
646
|
+
}
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (cmd === "sync-status") {
|
|
651
|
+
syncScope(project.key, "cli");
|
|
652
|
+
syncScope(PERSONAL_SCOPE.key, "cli");
|
|
653
|
+
try {
|
|
654
|
+
console.log(formatSyncStatus(getSyncStatus()));
|
|
655
|
+
} catch (e) {
|
|
656
|
+
console.error(`sync-status failed: ${(e as Error).message}`);
|
|
657
|
+
process.exit(2);
|
|
658
|
+
}
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (cmd === "pr-status") {
|
|
663
|
+
const flags = parseFlags(rest);
|
|
664
|
+
try {
|
|
665
|
+
const st = getPrStatus();
|
|
666
|
+
console.log(formatPrStatus(st));
|
|
667
|
+
if (flags.apply === "true") {
|
|
668
|
+
const applied = applyPrStatus(st);
|
|
669
|
+
if (applied.length === 0) {
|
|
670
|
+
console.log(`nothing to apply.`);
|
|
671
|
+
} else {
|
|
672
|
+
for (const a of applied) {
|
|
673
|
+
console.log(`applied ${a.memoryId.slice(0, 8)}: ${a.from} → ${a.to} (by ${a.by})`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
} catch (e) {
|
|
678
|
+
console.error(`pr-status failed: ${(e as Error).message}`);
|
|
679
|
+
process.exit(2);
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (cmd === "submit") {
|
|
685
|
+
const flags = parseFlags(rest);
|
|
686
|
+
const ids = positionalArgs(rest);
|
|
687
|
+
if (ids.length === 0) usage();
|
|
688
|
+
syncScope(project.key, "submit");
|
|
689
|
+
try {
|
|
690
|
+
const r = submitMemories(ids, { onto: flags.onto, base: flags.base });
|
|
691
|
+
for (const s of r.submitted) {
|
|
692
|
+
console.log(`submitted ${s.id} → ${path.relative(process.cwd(), s.filePath)} [${s.reviewState}]`);
|
|
693
|
+
}
|
|
694
|
+
for (const id of r.skippedIdentical) {
|
|
695
|
+
console.log(`already on branch: ${id} (identical content — outbox copy removed)`);
|
|
696
|
+
}
|
|
697
|
+
if (!r.committed) {
|
|
698
|
+
console.log(`nothing new to commit — branch ${r.branch} already holds these memories.`);
|
|
699
|
+
} else {
|
|
700
|
+
console.log(`committed on ${r.branch}.`);
|
|
701
|
+
}
|
|
702
|
+
console.log(`Next (needs your approval — open-memex never pushes for you):`);
|
|
703
|
+
console.log(` ${r.pushCommand}`);
|
|
704
|
+
console.log(` ${r.prCommand}`);
|
|
705
|
+
} catch (e) {
|
|
706
|
+
console.error(`submit failed: ${(e as Error).message}`);
|
|
707
|
+
process.exit(2);
|
|
708
|
+
}
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
520
712
|
if (cmd === "scopes") {
|
|
521
713
|
const { memories } = paths();
|
|
522
714
|
if (!fs.existsSync(memories)) {
|
package/src/config.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface MyOMemoryConfig {
|
|
|
13
13
|
keywordPersonalPatterns: string[];
|
|
14
14
|
redactPatterns: string[];
|
|
15
15
|
logLevel: "debug" | "info" | "warn" | "error";
|
|
16
|
+
/**
|
|
17
|
+
* In-repo project-memory directory, relative to the project root (2B/D23).
|
|
18
|
+
* Default `.ai/open-memex/`. Must stay relative and `..`-free so project
|
|
19
|
+
* memories can never escape the repo.
|
|
20
|
+
*/
|
|
21
|
+
memoryDir: string;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export const DEFAULT_CONFIG: MyOMemoryConfig = {
|
|
@@ -47,6 +53,7 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
|
|
|
47
53
|
// Add only your own extra patterns here.
|
|
48
54
|
redactPatterns: [],
|
|
49
55
|
logLevel: "info",
|
|
56
|
+
memoryDir: ".ai/open-memex",
|
|
50
57
|
};
|
|
51
58
|
|
|
52
59
|
function stripJsonComments(raw: string): string {
|
|
@@ -80,12 +87,21 @@ function toNonNegInt(v: unknown): number {
|
|
|
80
87
|
return n;
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
function toRelativeDir(v: unknown): string {
|
|
91
|
+
if (typeof v !== "string" || !v.trim()) throw new Error("must be a non-empty relative directory");
|
|
92
|
+
const t = v.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
93
|
+
if (!t || path.isAbsolute(t) || t.split("/").includes(".."))
|
|
94
|
+
throw new Error('must be a relative path without ".." (e.g. ".ai/open-memex")');
|
|
95
|
+
return t;
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
/** Keys users may change via `open-memex config set <key> <value>`, with validators. */
|
|
84
99
|
export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
|
|
85
100
|
maxProjectMemories: toNonNegInt,
|
|
86
101
|
maxProfileItems: toNonNegInt,
|
|
87
102
|
injectOnFirstTurn: toBool,
|
|
88
103
|
keywordCaptureEnabled: toBool,
|
|
104
|
+
memoryDir: toRelativeDir,
|
|
89
105
|
logLevel: (v) => {
|
|
90
106
|
if (v !== "info" && v !== "debug") throw new Error('must be "info" or "debug"');
|
|
91
107
|
return v;
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
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
|
+
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import { loadConfig } from "./config.ts";
|
|
26
|
+
import { projectRoot } from "./paths.ts";
|
|
27
|
+
import { parse as parseMemory, type ReviewState } from "./store/markdown.ts";
|
|
28
|
+
import { iterInRepoMemoryFiles } from "./store/markdown.ts";
|
|
29
|
+
import { promoteMemory, currentAuthor } from "./review.ts";
|
|
30
|
+
|
|
31
|
+
function fail(msg: string): never {
|
|
32
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sh(cmd: string, args: string[], cwd: string): string {
|
|
36
|
+
try {
|
|
37
|
+
return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
38
|
+
} catch (e) {
|
|
39
|
+
const err = e as { stderr?: string; message?: string };
|
|
40
|
+
const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
|
|
41
|
+
fail(`${cmd} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PrInfo {
|
|
46
|
+
number: number;
|
|
47
|
+
url: string;
|
|
48
|
+
state: "open" | "closed"; // GitHub PR state
|
|
49
|
+
merged: boolean;
|
|
50
|
+
mergedAt: string | null;
|
|
51
|
+
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
|
|
52
|
+
approvedBy: string[];
|
|
53
|
+
changesRequestedBy: string[];
|
|
54
|
+
base: string;
|
|
55
|
+
head: string;
|
|
56
|
+
title: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface GhPrJson {
|
|
60
|
+
number: number;
|
|
61
|
+
url: string;
|
|
62
|
+
state: string;
|
|
63
|
+
mergedAt: string | null;
|
|
64
|
+
isMerged?: boolean;
|
|
65
|
+
reviewDecision?: string | null;
|
|
66
|
+
baseRefName: string;
|
|
67
|
+
headRefName: string;
|
|
68
|
+
title: string;
|
|
69
|
+
reviews?: Array<{ author: { login: string }; state: string; submittedAt: string }>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read the PR for a branch (default: current branch) via the GitHub CLI. */
|
|
73
|
+
export function getPrInfo(branch?: string): PrInfo | null {
|
|
74
|
+
const root = projectRoot();
|
|
75
|
+
// `gh` must exist and be authenticated on the user's machine.
|
|
76
|
+
try {
|
|
77
|
+
execFileSync("gh", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
78
|
+
} catch {
|
|
79
|
+
fail("the GitHub CLI (`gh`) is not installed — install it and run `gh auth login` first.");
|
|
80
|
+
}
|
|
81
|
+
const ref = branch ?? sh("git", ["branch", "--show-current"], root);
|
|
82
|
+
if (!ref) fail("not on a branch — check out the memory branch first.");
|
|
83
|
+
let raw: string;
|
|
84
|
+
try {
|
|
85
|
+
raw = sh("gh", ["pr", "view", ref, "--json",
|
|
86
|
+
"number,url,state,mergedAt,reviewDecision,baseRefName,headRefName,title,reviews"], root);
|
|
87
|
+
} catch {
|
|
88
|
+
return null; // no PR for this branch yet
|
|
89
|
+
}
|
|
90
|
+
let j: GhPrJson;
|
|
91
|
+
try {
|
|
92
|
+
j = JSON.parse(raw) as GhPrJson;
|
|
93
|
+
} catch {
|
|
94
|
+
fail(`could not parse \`gh pr view\` output for branch ${ref}.`);
|
|
95
|
+
}
|
|
96
|
+
const approvedBy: string[] = [];
|
|
97
|
+
const changesRequestedBy: string[] = [];
|
|
98
|
+
// Latest review per author wins.
|
|
99
|
+
const latest = new Map<string, { state: string; at: string }>();
|
|
100
|
+
for (const r of j.reviews ?? []) {
|
|
101
|
+
const login = r.author?.login;
|
|
102
|
+
if (!login) continue;
|
|
103
|
+
const prev = latest.get(login);
|
|
104
|
+
if (!prev || r.submittedAt >= prev.at) latest.set(login, { state: r.state, at: r.submittedAt });
|
|
105
|
+
}
|
|
106
|
+
for (const [login, r] of latest) {
|
|
107
|
+
if (r.state === "APPROVED") approvedBy.push(login);
|
|
108
|
+
else if (r.state === "CHANGES_REQUESTED") changesRequestedBy.push(login);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
number: j.number,
|
|
112
|
+
url: j.url,
|
|
113
|
+
state: j.state === "OPEN" ? "open" : "closed",
|
|
114
|
+
merged: !!j.mergedAt,
|
|
115
|
+
mergedAt: j.mergedAt,
|
|
116
|
+
reviewDecision: (j.reviewDecision as PrInfo["reviewDecision"]) ?? null,
|
|
117
|
+
approvedBy,
|
|
118
|
+
changesRequestedBy,
|
|
119
|
+
base: j.baseRefName,
|
|
120
|
+
head: j.headRefName,
|
|
121
|
+
title: j.title,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface MemoryPrMapping {
|
|
126
|
+
memoryId: string;
|
|
127
|
+
from: ReviewState;
|
|
128
|
+
/** Null = no automatic transition; see reason. */
|
|
129
|
+
suggestedTo: ReviewState | null;
|
|
130
|
+
reason: string;
|
|
131
|
+
approvedBy: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface PrStatus {
|
|
135
|
+
branch: string;
|
|
136
|
+
pr: PrInfo | null;
|
|
137
|
+
mappings: MemoryPrMapping[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Map the branch PR's state onto each in-repo memory's review_state. */
|
|
141
|
+
export function getPrStatus(branch?: string): PrStatus {
|
|
142
|
+
const root = projectRoot();
|
|
143
|
+
const cfg = loadConfig();
|
|
144
|
+
const current = branch ?? sh("git", ["branch", "--show-current"], root);
|
|
145
|
+
const pr = getPrInfo(current);
|
|
146
|
+
|
|
147
|
+
const mappings: MemoryPrMapping[] = [];
|
|
148
|
+
for (const fp of iterInRepoMemoryFiles(root, cfg.memoryDir)) {
|
|
149
|
+
const raw = fs.readFileSync(fp, "utf8");
|
|
150
|
+
const parsed = parseMemory(raw);
|
|
151
|
+
if (!parsed) continue;
|
|
152
|
+
const from = parsed.fm.review_state ?? "draft";
|
|
153
|
+
const id = parsed.fm.id;
|
|
154
|
+
if (!pr) {
|
|
155
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `no PR for branch ${current} yet`, approvedBy: null });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (from === "published") {
|
|
159
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: "already published", approvedBy: null });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (from === "rejected") {
|
|
163
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `stays rejected — a PR ${pr.merged ? "merge" : "approval"} never overrides a human rejection`, approvedBy: null });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (pr.merged) {
|
|
167
|
+
if (from === "proposed" || from === "approved") {
|
|
168
|
+
mappings.push({ memoryId: id, from, suggestedTo: "published", reason: `PR #${pr.number} merged`, approvedBy: null });
|
|
169
|
+
} else {
|
|
170
|
+
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `PR #${pr.number} merged, but memory is "${from}"`, approvedBy: null });
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (pr.reviewDecision === "APPROVED" && from === "proposed") {
|
|
175
|
+
const by = pr.approvedBy[0] ?? null;
|
|
176
|
+
mappings.push({
|
|
177
|
+
memoryId: id, from, suggestedTo: "approved",
|
|
178
|
+
reason: `PR #${pr.number} approved${by ? ` by @${by}` : ""}`, approvedBy: by,
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (pr.reviewDecision === "CHANGES_REQUESTED") {
|
|
183
|
+
const by = pr.changesRequestedBy[0] ?? null;
|
|
184
|
+
mappings.push({
|
|
185
|
+
memoryId: id, from, suggestedTo: null,
|
|
186
|
+
reason: `PR #${pr.number} has changes requested${by ? ` by @${by}` : ""} — run \`open-memex promote ${id} --reject --note "..."\` yourself if you agree`,
|
|
187
|
+
approvedBy: null,
|
|
188
|
+
});
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
mappings.push({
|
|
192
|
+
memoryId: id, from, suggestedTo: null,
|
|
193
|
+
reason: pr.state === "open" ? `PR #${pr.number} still awaiting review` : `PR #${pr.number} is closed without merge`,
|
|
194
|
+
approvedBy: null,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return { branch: current, pr, mappings };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface PrApplyResult {
|
|
201
|
+
memoryId: string;
|
|
202
|
+
from: ReviewState;
|
|
203
|
+
to: ReviewState;
|
|
204
|
+
by: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Apply the mapped transitions locally (no push — D27). Advances step by
|
|
208
|
+
* step through the ladder, so a merged PR moves proposed → approved →
|
|
209
|
+
* published with each step in the audit trail. */
|
|
210
|
+
export function applyPrStatus(st: PrStatus): PrApplyResult[] {
|
|
211
|
+
const out: PrApplyResult[] = [];
|
|
212
|
+
const actor = currentAuthor();
|
|
213
|
+
const order: ReviewState[] = ["draft", "proposed", "approved", "published"];
|
|
214
|
+
for (const m of st.mappings) {
|
|
215
|
+
if (!m.suggestedTo || order.indexOf(m.from) >= order.indexOf(m.suggestedTo)) continue;
|
|
216
|
+
const by = m.approvedBy ?? actor;
|
|
217
|
+
const note = m.suggestedTo === "published" && st.pr
|
|
218
|
+
? `PR #${st.pr.number} merged`
|
|
219
|
+
: m.reason;
|
|
220
|
+
let cur = m.from;
|
|
221
|
+
while (order.indexOf(cur) < order.indexOf(m.suggestedTo)) {
|
|
222
|
+
const r = promoteMemory(m.memoryId, { by, note });
|
|
223
|
+
cur = r.to;
|
|
224
|
+
out.push({ memoryId: m.memoryId, from: r.from, to: r.to, by });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function formatPrStatus(st: PrStatus): string {
|
|
231
|
+
const lines: string[] = [];
|
|
232
|
+
lines.push(`branch: ${st.branch}`);
|
|
233
|
+
if (!st.pr) {
|
|
234
|
+
lines.push(`PR: none yet — push the branch and open one, then re-run.`);
|
|
235
|
+
} else {
|
|
236
|
+
const p = st.pr;
|
|
237
|
+
lines.push(`PR #${p.number}: ${p.title}`);
|
|
238
|
+
lines.push(` ${p.url}`);
|
|
239
|
+
lines.push(` state: ${p.merged ? `merged${p.mergedAt ? ` at ${p.mergedAt}` : ""}` : p.state}` +
|
|
240
|
+
(p.reviewDecision ? `, reviews: ${p.reviewDecision}` : ""));
|
|
241
|
+
if (p.approvedBy.length > 0) lines.push(` approved by: ${p.approvedBy.map((l) => "@" + l).join(", ")}`);
|
|
242
|
+
if (p.changesRequestedBy.length > 0) lines.push(` changes requested by: ${p.changesRequestedBy.map((l) => "@" + l).join(", ")}`);
|
|
243
|
+
}
|
|
244
|
+
if (st.mappings.length === 0) {
|
|
245
|
+
lines.push(`no in-repo memories on this branch.`);
|
|
246
|
+
} else {
|
|
247
|
+
lines.push(`memories (${st.mappings.length}):`);
|
|
248
|
+
for (const m of st.mappings) {
|
|
249
|
+
const arrow = m.suggestedTo ? ` → [${m.suggestedTo}]` : "";
|
|
250
|
+
lines.push(` ${m.memoryId.slice(0, 8)} [${m.from}]${arrow} ${m.reason}`);
|
|
251
|
+
}
|
|
252
|
+
if (st.mappings.some((m) => m.suggestedTo)) {
|
|
253
|
+
lines.push(`run with --apply to perform the mapped transitions locally (no push).`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return lines.join("\n");
|
|
257
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -93,6 +93,12 @@ const plugin: Plugin = async ({ worktree, directory }) => {
|
|
|
93
93
|
updated_at: rfc,
|
|
94
94
|
supersedes: null,
|
|
95
95
|
superseded_by: null,
|
|
96
|
+
review_state: "draft",
|
|
97
|
+
proposed_by: null,
|
|
98
|
+
approved_by: null,
|
|
99
|
+
derived_from: null,
|
|
100
|
+
review_note: null,
|
|
101
|
+
review_history: [],
|
|
96
102
|
};
|
|
97
103
|
try {
|
|
98
104
|
const { filePath } = writeMemoryFile(fm, content);
|
package/src/init.ts
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.ts";
|
|
8
|
+
import { projectRoot } from "./paths.ts";
|
|
9
9
|
|
|
10
10
|
const MARKER = "<!-- open-memex -->";
|
|
11
11
|
|
|
@@ -50,8 +50,10 @@ const INSTRUCTIONS = `${MARKER}
|
|
|
50
50
|
> Applies only when the \`open-memex\` MCP server is available in this session
|
|
51
51
|
> (the \`memory_*\` tools exist). Otherwise ignore this section.
|
|
52
52
|
|
|
53
|
-
You have a local memory MCP server (\`open-memex\`) with
|
|
54
|
-
\`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget
|
|
53
|
+
You have a local memory MCP server (\`open-memex\`) with eleven tools:
|
|
54
|
+
\`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`,
|
|
55
|
+
\`memory_status\`, \`memory_submit\`, \`memory_propose\`, \`memory_promote\`, \`memory_resolve\`,
|
|
56
|
+
\`memory_pr_status\`.
|
|
55
57
|
|
|
56
58
|
- BE PROACTIVE. When the user shares something worth remembering across sessions
|
|
57
59
|
(a decision, a preference, a project convention, a fix and its cause), call
|
|
@@ -62,21 +64,32 @@ You have a local memory MCP server (\`open-memex\`) with five tools:
|
|
|
62
64
|
- Memories default to this project's scope; use the \`personal\` scope for facts about
|
|
63
65
|
the user that hold across all projects. When a saved fact becomes outdated, call
|
|
64
66
|
\`memory_supersede\` instead of adding a duplicate.
|
|
65
|
-
`;
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
68
|
+
## Syncing project memories for review (D26)
|
|
69
|
+
|
|
70
|
+
Project memories you save land in a local outbox first — they are NOT in git yet.
|
|
71
|
+
Syncing them into the repo for review is an explicit, user-approved step:
|
|
72
|
+
|
|
73
|
+
- At session start, and when you finish a meaningful chunk of work, call
|
|
74
|
+
\`memory_status\`. If the outbox has drafts, summarize them (one line each) and ask
|
|
75
|
+
the user which ones to sync. Sync NOTHING the user did not name.
|
|
76
|
+
- When the user approves, ask ONE follow-up: a separate memory-only branch/PR
|
|
77
|
+
(recommended), or fold the memories into the current branch alongside code?
|
|
78
|
+
A "yes, you do it" answer covers the whole chain — branch, local commit,
|
|
79
|
+
push, PR creation — do NOT re-ask at each step. If the user says they will
|
|
80
|
+
do it themselves, hand them the printed push/PR commands and do nothing.
|
|
81
|
+
Then call \`memory_submit\` with the approved ids (\`onto\` = current branch name
|
|
82
|
+
only for the fold-into-code path). It creates the branch, copies the drafts in
|
|
83
|
+
as \`proposed\`, commits locally, and prints the push + PR commands.
|
|
84
|
+
- Base branch for the memory PR defaults to the branch you are on; the user may
|
|
85
|
+
redirect it to the integration branch (main) for branch-independent knowledge.
|
|
86
|
+
- If anything conflicts (same id with different content, push rejected), STOP and
|
|
87
|
+
let the user judge — never overwrite.
|
|
88
|
+
- After the PR merges, call \`memory_pr_status\` (with \`apply\` when the user
|
|
89
|
+
approves) to map the PR's review state back onto each memory — merged means
|
|
90
|
+
\`published\`, an approval means \`approved\` (credited to the reviewer).
|
|
91
|
+
- \`personal\` scope memories NEVER leave the machine.
|
|
92
|
+
`;
|
|
80
93
|
|
|
81
94
|
function writeMcpJson(root: string, client: string, force: boolean): string | null {
|
|
82
95
|
if (client === "opencode") return writeOpencodeMcpJson(root, force);
|