open-memex 0.3.0 → 0.4.0-alpha.4

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.
@@ -2,7 +2,12 @@ import { createHash } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { db } from "./db.ts";
5
- import { paths, memoriesDirPath } from "../paths.ts";
5
+ import {
6
+ paths,
7
+ projectRoot,
8
+ inRepoMemoriesDirPath,
9
+ } from "../paths.ts";
10
+ import { loadConfig } from "../config.ts";
6
11
  import { cjkIndexText } from "../retrieve/cjk.ts";
7
12
  import {
8
13
  parse,
@@ -97,25 +102,33 @@ export function findDuplicates(
97
102
  return { exact, near: near.slice(0, 3) };
98
103
  }
99
104
 
100
- /** Locate a memory file by id: index first, then a full dir scan fallback. */
105
+ /** Locate a memory file by id: index file_path first, then dir-scan fallback. */
101
106
  export function findMemoryFile(id: string): MemoryFile | null {
102
107
  const { memories } = paths();
103
108
  let filePath: string | null = null;
104
109
  try {
105
110
  const row = db()
106
- .prepare(`SELECT scope_key FROM memories WHERE id = ?`)
107
- .get(id) as { scope_key: string } | undefined;
108
- if (row) {
109
- const p = path.join(memoriesDirPath(row.scope_key), `${id}.md`);
110
- if (fs.existsSync(p)) filePath = p;
111
- }
111
+ .prepare(`SELECT file_path FROM memories WHERE id = ?`)
112
+ .get(id) as { file_path: string } | undefined;
113
+ if (row && fs.existsSync(row.file_path)) filePath = row.file_path;
112
114
  } catch {
113
115
  // index unavailable — fall through to scan
114
116
  }
115
- if (!filePath && fs.existsSync(memories)) {
116
- for (const entry of fs.readdirSync(memories, { withFileTypes: true })) {
117
- if (!entry.isDirectory()) continue;
118
- const p = path.join(memories, entry.name, `${id}.md`);
117
+ if (!filePath) {
118
+ // Fallback scan: appdata scope dirs + the in-repo dir (2B/D24).
119
+ const dirs: string[] = [];
120
+ if (fs.existsSync(memories)) {
121
+ for (const entry of fs.readdirSync(memories, { withFileTypes: true })) {
122
+ if (entry.isDirectory()) dirs.push(path.join(memories, entry.name));
123
+ }
124
+ }
125
+ try {
126
+ dirs.push(inRepoMemoriesDirPath(projectRoot(), loadConfig().memoryDir));
127
+ } catch {
128
+ /* ignore */
129
+ }
130
+ for (const dir of dirs) {
131
+ const p = path.join(dir, `${id}.md`);
119
132
  if (fs.existsSync(p)) {
120
133
  filePath = p;
121
134
  break;
@@ -225,6 +238,11 @@ export function supersede(
225
238
  updated_at: now,
226
239
  supersedes: oldId,
227
240
  superseded_by: null,
241
+ review_state: "draft",
242
+ proposed_by: null,
243
+ approved_by: null,
244
+ derived_from: null,
245
+ review_note: null,
228
246
  };
229
247
  const dir = path.dirname(oldMf.filePath);
230
248
  const newPath = path.join(dir, `${newFm.id}.md`);
@@ -2,7 +2,11 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import yaml from "js-yaml";
5
- import { memoriesDirFor, memoriesDirPath } from "../paths.ts";
5
+ import {
6
+ memoriesDirFor,
7
+ memoriesDirPath,
8
+ inRepoMemoriesDirPath,
9
+ } from "../paths.ts";
6
10
 
7
11
  /** v2 content-kind taxonomy (V2-DESIGN §3.1). `type` = what the memory IS. */
8
12
  export const MEMORY_TYPE_TAXONOMY = [
@@ -52,6 +56,62 @@ export interface Frontmatter {
52
56
  updated_at: string; // RFC 3339
53
57
  supersedes: string | null; // on the NEW memory → points BACK (§3.3)
54
58
  superseded_by: string | null; // on the OLD memory → points FORWARD
59
+ /** 2B/D25: where this memory sits in the propose → promote workflow. */
60
+ review_state: ReviewState;
61
+ /** Who proposed / approved it (git user.name, fallback OS user). */
62
+ proposed_by: string | null;
63
+ approved_by: string | null;
64
+ /** For propose-copies: the personal memory this was derived from. */
65
+ derived_from: string | null;
66
+ /** Curator note on the latest review transition (e.g. rejection reason). */
67
+ review_note: string | null;
68
+ /** 2B/D29: append-only audit trail of review transitions. Lives in the
69
+ * file (source of truth), so it travels through branches and PRs. */
70
+ review_history: ReviewTransition[];
71
+ }
72
+
73
+ /** One step in a memory's review lifecycle — who moved it, when, why. */
74
+ export interface ReviewTransition {
75
+ at: string; // RFC 3339
76
+ by: string; // author identity (git user.name, fallback OS user)
77
+ from: ReviewState;
78
+ to: ReviewState;
79
+ note: string | null;
80
+ }
81
+
82
+ /** Defensive parse: malformed entries are dropped, never fatal. */
83
+ export function asReviewHistory(v: unknown): ReviewTransition[] {
84
+ if (!Array.isArray(v)) return [];
85
+ const out: ReviewTransition[] = [];
86
+ for (const e of v) {
87
+ if (!e || typeof e !== "object") continue;
88
+ const r = e as Record<string, unknown>;
89
+ const from = asReviewState(r.from);
90
+ const to = asReviewState(r.to);
91
+ const at = typeof r.at === "string" && r.at ? r.at : null;
92
+ const by = typeof r.by === "string" && r.by ? r.by : null;
93
+ if (!at || !by) continue;
94
+ out.push({
95
+ at,
96
+ by,
97
+ from,
98
+ to,
99
+ note: typeof r.note === "string" && r.note ? r.note : null,
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** Review lifecycle for shared memories (2B/D25): draft → proposed →
106
+ * approved/rejected → published. Personal memories stay `draft`. */
107
+ export type ReviewState = "draft" | "proposed" | "approved" | "rejected" | "published";
108
+
109
+ const REVIEW_STATES: ReviewState[] = ["draft", "proposed", "approved", "rejected", "published"];
110
+
111
+ export function asReviewState(v: unknown): ReviewState {
112
+ return typeof v === "string" && (REVIEW_STATES as string[]).includes(v)
113
+ ? (v as ReviewState)
114
+ : "draft";
55
115
  }
56
116
 
57
117
  export interface MemoryFile {
@@ -179,6 +239,16 @@ export function normalizeFrontmatter(
179
239
  typeof raw.superseded_by === "string" && raw.superseded_by
180
240
  ? raw.superseded_by
181
241
  : null,
242
+ review_state: asReviewState(raw.review_state),
243
+ proposed_by:
244
+ typeof raw.proposed_by === "string" && raw.proposed_by ? raw.proposed_by : null,
245
+ approved_by:
246
+ typeof raw.approved_by === "string" && raw.approved_by ? raw.approved_by : null,
247
+ derived_from:
248
+ typeof raw.derived_from === "string" && raw.derived_from ? raw.derived_from : null,
249
+ review_note:
250
+ typeof raw.review_note === "string" && raw.review_note ? raw.review_note : null,
251
+ review_history: asReviewHistory(raw.review_history),
182
252
  };
183
253
  }
184
254
 
@@ -217,6 +287,9 @@ export function writeMemoryFile(
217
287
  fm: Frontmatter,
218
288
  body: string,
219
289
  ): { filePath: string; mtimeMs: number } {
290
+ // 2B/D26: project drafts live in appdata (the outbox) — the in-repo dir
291
+ // only ever holds submitted memories (proposed/approved/published).
292
+ // Personal stays in appdata and never leaves the machine.
220
293
  const dir = memoriesDirFor(fm.scope_key);
221
294
  const filePath = path.join(dir, `${fm.id}.md`);
222
295
  fs.writeFileSync(filePath, serialize(fm, body), "utf8");
@@ -224,14 +297,16 @@ export function writeMemoryFile(
224
297
  return { filePath, mtimeMs: st.mtimeMs };
225
298
  }
226
299
 
227
- export function deleteMemoryFile(scopeKey: string, id: string): boolean {
228
- const dir = memoriesDirPath(scopeKey);
229
- const filePath = path.join(dir, `${id}.md`);
230
- if (fs.existsSync(filePath)) {
231
- fs.unlinkSync(filePath);
232
- return true;
300
+ /** Yield `*.md` files in the in-repo dir (read path — never creates it). */
301
+ export function* iterInRepoMemoryFiles(
302
+ root: string,
303
+ memoryDir: string,
304
+ ): Generator<string> {
305
+ const dir = inRepoMemoriesDirPath(root, memoryDir);
306
+ if (!fs.existsSync(dir)) return;
307
+ for (const name of fs.readdirSync(dir)) {
308
+ if (name.endsWith(".md")) yield path.join(dir, name);
233
309
  }
234
- return false;
235
310
  }
236
311
 
237
312
  export function readMemoryFile(filePath: string): MemoryFile | null {
package/src/store/sync.ts CHANGED
@@ -1,17 +1,21 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { db } from "./db.ts";
3
4
  import { cjkIndexText } from "../retrieve/cjk.ts";
4
5
  import { contentHash, repairChain } from "./lifecycle.ts";
5
6
  import {
6
7
  iterMemoryFiles,
8
+ iterInRepoMemoryFiles,
7
9
  readMemoryFile,
8
10
  timeToMs,
9
11
  type MemoryFile,
10
12
  } from "./markdown.ts";
13
+ import { projectRoot, paths } from "../paths.ts";
14
+ import { loadConfig } from "../config.ts";
11
15
 
12
16
  const UPSERT_SQL = `
13
- INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at)
14
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
17
+ INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at, review_state)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
15
19
  ON CONFLICT(id) DO UPDATE SET
16
20
  scope_key = excluded.scope_key,
17
21
  scope = excluded.scope,
@@ -27,6 +31,7 @@ const UPSERT_SQL = `
27
31
  content_hash = excluded.content_hash,
28
32
  superseded_by = excluded.superseded_by,
29
33
  source = excluded.source,
34
+ review_state = excluded.review_state,
30
35
  file_path = excluded.file_path,
31
36
  mtime_ms = excluded.mtime_ms,
32
37
  updated_at = excluded.updated_at
@@ -77,6 +82,7 @@ function writeRow(mf: MemoryFile, mtimeMs: number): void {
77
82
  mtimeMs,
78
83
  timeToMs(fm.created_at),
79
84
  timeToMs(fm.updated_at),
85
+ fm.review_state ?? "draft",
80
86
  );
81
87
  }
82
88
 
@@ -91,7 +97,7 @@ export interface SyncStats {
91
97
  scanned: number;
92
98
  }
93
99
 
94
- export function syncScope(scopeKey: string): SyncStats {
100
+ export function syncScope(scopeKey: string, kind: SyncKind = "auto"): SyncStats {
95
101
  const d = db();
96
102
  const stats: SyncStats = { added: 0, updated: 0, removed: 0, scanned: 0 };
97
103
 
@@ -101,7 +107,22 @@ export function syncScope(scopeKey: string): SyncStats {
101
107
  const existingById = new Map(existing.map((r) => [r.id, r]));
102
108
  const seen = new Set<string>();
103
109
 
104
- for (const fp of iterMemoryFiles(scopeKey)) {
110
+ // 2B/D26: project scopes have two homes — the appdata outbox (drafts,
111
+ // branch-independent) and the in-repo dir (submitted memories, following
112
+ // the current branch). No migration: appdata files stay put until an
113
+ // explicit `submit` moves them. On the near-impossible id collision the
114
+ // in-repo (submitted) copy wins, so scan appdata first.
115
+ const filePaths: string[] = [];
116
+ if (scopeKey.startsWith("project__")) {
117
+ const root = projectRoot();
118
+ const memoryDir = loadConfig().memoryDir;
119
+ for (const fp of iterMemoryFiles(scopeKey)) filePaths.push(fp);
120
+ for (const fp of iterInRepoMemoryFiles(root, memoryDir)) filePaths.push(fp);
121
+ } else {
122
+ for (const fp of iterMemoryFiles(scopeKey)) filePaths.push(fp);
123
+ }
124
+
125
+ for (const fp of filePaths) {
105
126
  const mf = readMemoryFile(fp);
106
127
  if (!mf) continue;
107
128
  stats.scanned++;
@@ -123,5 +144,65 @@ export function syncScope(scopeKey: string): SyncStats {
123
144
  }
124
145
  }
125
146
 
147
+ recordSync(scopeKey, kind, stats);
126
148
  return stats;
127
149
  }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // D31: last-sync visibility
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /** What triggered the sync — shown in sync-status so the user can see it. */
156
+ export type SyncKind = "session" | "request" | "cli" | "submit" | "auto";
157
+
158
+ export interface SyncStateEntry {
159
+ lastSyncAt: string; // RFC 3339
160
+ lastSyncKind: SyncKind;
161
+ added: number;
162
+ updated: number;
163
+ removed: number;
164
+ scanned: number;
165
+ }
166
+
167
+ function syncStatePath(): string {
168
+ return path.join(paths().root, "sync-state.json");
169
+ }
170
+
171
+ /** Best-effort: a failed write never breaks the sync itself. */
172
+ export function recordSync(scopeKey: string, kind: SyncKind, stats: SyncStats): void {
173
+ try {
174
+ const p = syncStatePath();
175
+ let all: Record<string, SyncStateEntry> = {};
176
+ if (fs.existsSync(p)) {
177
+ try {
178
+ all = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, SyncStateEntry>;
179
+ } catch {
180
+ /* corrupt — start fresh */
181
+ }
182
+ }
183
+ all[scopeKey] = {
184
+ lastSyncAt: new Date().toISOString(),
185
+ lastSyncKind: kind,
186
+ added: stats.added,
187
+ updated: stats.updated,
188
+ removed: stats.removed,
189
+ scanned: stats.scanned,
190
+ };
191
+ fs.writeFileSync(p, JSON.stringify(all, null, 2), "utf8");
192
+ } catch {
193
+ /* visibility is advisory — never fail a sync over it */
194
+ }
195
+ }
196
+
197
+ export function readSyncState(scopeKey: string): SyncStateEntry | null {
198
+ try {
199
+ const p = syncStatePath();
200
+ if (!fs.existsSync(p)) return null;
201
+ const all = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, unknown>;
202
+ const e = all?.[scopeKey] as SyncStateEntry | undefined;
203
+ if (!e || typeof e.lastSyncAt !== "string") return null;
204
+ return e;
205
+ } catch {
206
+ return null;
207
+ }
208
+ }