open-memex 0.4.0-alpha.7 → 0.4.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/src/cli.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  type Frontmatter,
19
19
  } from "./store/markdown.ts";
20
20
  import { loadConfig } from "./config.ts";
21
- import { paths } from "./paths.ts";
21
+ import { paths, projectRoot } from "./paths.ts";
22
22
  import { redact } from "./redact.ts";
23
23
  import { resolveMcpCommand } from "./init.ts";
24
24
  import fs from "node:fs";
@@ -30,7 +30,10 @@ import { fileURLToPath } from "node:url";
30
30
  const COMMAND_HELP: Record<string, string> = {
31
31
  where: `Show which project scope the current directory resolves to, and where its data lives.
32
32
 
33
- Usage: open-memex where`,
33
+ Usage: open-memex where
34
+
35
+ Example:
36
+ open-memex where`,
34
37
 
35
38
  list: `List memories in a scope, newest first.
36
39
 
@@ -71,15 +74,24 @@ Example:
71
74
 
72
75
  supersede: `Replace a memory with a newer version. The old one is kept as history.
73
76
 
74
- Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]`,
77
+ Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
78
+
79
+ Example:
80
+ open-memex supersede 01ABC "We deploy on Thursdays now"`,
75
81
 
76
82
  status: `Change a memory's lifecycle status.
77
83
 
78
- Usage: open-memex status <id> active|deprecated|retracted|archived`,
84
+ Usage: open-memex status <id> active|deprecated|retracted|archived
85
+
86
+ Example:
87
+ open-memex status 01ABC deprecated`,
79
88
 
80
89
  forget: `Delete a memory by id.
81
90
 
82
- Usage: open-memex forget <id>`,
91
+ Usage: open-memex forget <id>
92
+
93
+ Example:
94
+ open-memex forget 01ABC`,
83
95
 
84
96
  propose: `Copy personal memories into the project outbox as review drafts.
85
97
  The personal originals stay put. Nothing enters git at this step.
@@ -116,13 +128,76 @@ Usage: open-memex resolve [id-or-path]
116
128
 
117
129
  With no argument, lists conflicts. With an id or file path, shows the
118
130
  3-way merge (base / outbox / repo) so you can resolve it by hand.
119
- Conflicts are never auto-resolved.`,
131
+ Conflicts are never auto-resolved.
132
+
133
+ Examples:
134
+ open-memex resolve
135
+ open-memex resolve 01ABC`,
120
136
 
121
137
  "sync-status": `Show the project memory sync pipeline: when the index last synced
122
138
  and what triggered it, drafts waiting in the outbox (appdata), memories in the
123
139
  repo awaiting review or published, and repo files not yet committed.
124
140
 
125
- Usage: open-memex sync-status`,
141
+ Usage: open-memex sync-status
142
+
143
+ Example:
144
+ open-memex sync-status`,
145
+
146
+ pull: `Pull shared project memories from the git remote: fetch + fast-forward
147
+ only. Never auto-merges — a diverged branch fails with a clear message and is
148
+ left for you to resolve by hand. On success the local index re-syncs.
149
+
150
+ Usage: open-memex pull
151
+
152
+ Example:
153
+ open-memex pull`,
154
+
155
+ push: `Push the current branch (with its submitted memories) to the git
156
+ remote. Explicit only — open-memex never pushes on its own.
157
+
158
+ Usage: open-memex push
159
+
160
+ Example:
161
+ open-memex push`,
162
+
163
+ export: `Export memories to a portable .tar.gz bundle (markdown source of
164
+ truth + manifest.json) for moving to another machine or another app.
165
+ Excludes visibility:private memories by default; --all includes everything.
166
+
167
+ Usage: open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
168
+
169
+ Flags:
170
+ --scope project (default), personal, or both
171
+ --type filter by memory type
172
+ --tag filter by tag
173
+ --all, -a include private memories (full migration)
174
+ -o output file (default: ./open-memex-export-<timestamp>.tar.gz)
175
+
176
+ Examples:
177
+ open-memex export -o backup.tar.gz
178
+ open-memex export --scope both --all -o full-migration.tar.gz`,
179
+
180
+ import: `Import a bundle created by \`open-memex export\`. Personal memories
181
+ go to the personal dir; project memories are re-keyed to the current project
182
+ and land in the outbox as drafts. Existing identical memories are skipped;
183
+ conflicting ids are reported, never overwritten.
184
+
185
+ Usage: open-memex import <bundle.tar.gz> [--dry-run]
186
+
187
+ Examples:
188
+ open-memex import backup.tar.gz --dry-run
189
+ open-memex import backup.tar.gz`,
190
+
191
+ "distill-agents": `Propose an AGENTS.md snippet distilled from project
192
+ memories (decisions, constraints, lessons, gotchas, howtos). Prints markdown
193
+ to stdout, or writes it with -o. Review and merge by hand — open-memex never
194
+ rewrites your AGENTS.md on its own.
195
+
196
+ Usage: open-memex distill-agents [--scope project|personal] [--type t1,t2] [--limit N] [-o <file>]
197
+
198
+ Examples:
199
+ open-memex distill-agents
200
+ open-memex distill-agents --type decision,gotcha -o agents-snippet.md`,
126
201
 
127
202
  submit: `Move outbox drafts into the repo for review: copies the drafts into
128
203
  the repo memory dir as proposed (a local-approved copy keeps its approval),
@@ -148,15 +223,25 @@ overwritten. Report-only by default.
148
223
  Usage: open-memex pr-status [--apply]
149
224
 
150
225
  Flags:
151
- --apply write the transitions locally (still never pushes)`,
226
+ --apply write the transitions locally (still never pushes)
227
+
228
+ Examples:
229
+ open-memex pr-status
230
+ open-memex pr-status --apply`,
152
231
 
153
232
  reindex: `Rebuild the SQLite index from the markdown files.
154
233
 
155
- Usage: open-memex reindex`,
234
+ Usage: open-memex reindex
235
+
236
+ Example:
237
+ open-memex reindex`,
156
238
 
157
239
  scopes: `List the known scopes (personal + project).
158
240
 
159
- Usage: open-memex scopes`,
241
+ Usage: open-memex scopes
242
+
243
+ Example:
244
+ open-memex scopes`,
160
245
 
161
246
  migrate: `Move memories between scopes, or convert a legacy my-o-memory data dir.
162
247
 
@@ -165,18 +250,30 @@ Usage: open-memex migrate [--from <key>] [--to <key>] [--dry-run] [--on-conflict
165
250
 
166
251
  Flags:
167
252
  --from / --to scope keys (default: current project → personal)
168
- --dry-run preview without moving anything
253
+ --dry-run preview without moving anything (--to-v2 previews the legacy
254
+ files in place, including per-file conversion plans)
169
255
  --on-conflict newer (default), overwrite, or skip
170
256
  --to-v2 convert a legacy my-o-memory data dir to the v2 layout
171
257
 
172
- Always preview with --dry-run first; nothing moves without confirmation.`,
258
+ Always preview with --dry-run first; nothing moves without confirmation.
259
+ On Windows, if another program holds the legacy folder open, the backup
260
+ rename fails with an actionable message instead of a stack trace — close
261
+ the program and re-run.
262
+
263
+ Examples:
264
+ open-memex migrate --dry-run
265
+ open-memex migrate --from personal --to project --dry-run`,
173
266
 
174
267
  mcp: `Start the stdio MCP server (the same server editors connect to).
175
268
 
176
269
  Usage: open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
177
270
 
178
271
  Flags:
179
- --print-config print the MCP client config instead of starting the server`,
272
+ --print-config print the MCP client config instead of starting the server
273
+
274
+ Examples:
275
+ open-memex mcp
276
+ open-memex mcp --print-config vscode`,
180
277
 
181
278
  init: `One-command project setup: writes the MCP config for your editor and the
182
279
  agent memory instructions. Existing files are merged, never clobbered.
@@ -188,24 +285,35 @@ Flags:
188
285
  --client editor to configure (default: auto-detect)
189
286
  --instructions personal (default, ~/.copilot/copilot-instructions.md) or project
190
287
  --force overwrite existing config
191
- --yes accept all defaults, never prompt`,
288
+ --yes accept all defaults, never prompt
289
+
290
+ Examples:
291
+ open-memex init
292
+ open-memex init --client cursor --yes`,
192
293
 
193
294
  config: `Show config, or set a key.
194
295
 
195
296
  Usage: open-memex config [set <key> <value>]
196
297
 
197
- Example:
298
+ Examples:
299
+ open-memex config
198
300
  open-memex config set sync.autoPull false`,
199
301
 
200
302
  capture: `Preview what the keyword-capture watcher would extract from text.
201
303
 
202
- Usage: open-memex capture --dry-run "text"`,
304
+ Usage: open-memex capture --dry-run "text"
305
+
306
+ Example:
307
+ open-memex capture --dry-run "remember: we deploy on Fridays"`,
203
308
 
204
309
  doctor: `Environment health check: Node version, config source, scope resolution,
205
310
  storage writability, then boots a real MCP server and runs initialize +
206
311
  tools/list against it — all eleven tools must show up.
207
312
 
208
- Usage: open-memex doctor`,
313
+ Usage: open-memex doctor
314
+
315
+ Example:
316
+ open-memex doctor`,
209
317
  };
210
318
 
211
319
  function usage(exitCode = 1): never {
@@ -223,6 +331,11 @@ Usage:
223
331
  open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
224
332
  open-memex resolve [id-or-path]
225
333
  open-memex sync-status
334
+ open-memex pull
335
+ open-memex push
336
+ open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
337
+ open-memex import <bundle.tar.gz> [--dry-run]
338
+ open-memex distill-agents [--scope project|personal] [--type t1,t2] [--limit N] [-o <file>]
226
339
  open-memex submit <id...> [--branch <name>] [--base <branch>]
227
340
  open-memex pr-status [--apply]
228
341
  open-memex reindex
@@ -237,6 +350,9 @@ Usage:
237
350
  open-memex capture --dry-run "text"
238
351
  open-memex doctor
239
352
 
353
+ Every command has its own help with description and examples:
354
+ open-memex <command> --help (or -h)
355
+
240
356
  One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) writes
241
357
  the MCP config for your editor (\`.vscode/mcp.json\`, \`.cursor/mcp.json\`,
242
358
  \`opencode.jsonc\`, or Visual Studio's solution-level \`.mcp.json\`) — no copy-paste
@@ -268,18 +384,52 @@ Run \`open-memex <command> --help\` for details on a single command.`);
268
384
  process.exit(exitCode);
269
385
  }
270
386
 
387
+ /**
388
+ * Build a saveConfig patch for a (possibly dotted) config key, preserving
389
+ * sibling keys already present in the nested object.
390
+ */
391
+ function setConfigPath(key: string, value: unknown): Record<string, unknown> {
392
+ const parts = key.split(".");
393
+ if (parts.length === 1) return { [key]: value };
394
+ const cfg = loadConfig() as unknown as Record<string, unknown>;
395
+ const top = parts[0]!;
396
+ const cur =
397
+ cfg[top] && typeof cfg[top] === "object"
398
+ ? { ...(cfg[top] as Record<string, unknown>) }
399
+ : {};
400
+ let node: Record<string, unknown> = cur;
401
+ for (let i = 1; i < parts.length - 1; i++) {
402
+ const seg = parts[i]!;
403
+ const nxt =
404
+ node[seg] && typeof node[seg] === "object"
405
+ ? { ...(node[seg] as Record<string, unknown>) }
406
+ : {};
407
+ node[seg] = nxt;
408
+ node = nxt;
409
+ }
410
+ node[parts[parts.length - 1]!] = value;
411
+ return { [top]: cur };
412
+ }
413
+
271
414
  function parseFlags(argv: string[]): Record<string, string> {
272
415
  const out: Record<string, string> = {};
273
416
  for (let i = 0; i < argv.length; i++) {
274
417
  const a = argv[i]!;
275
418
  if (a.startsWith("--")) {
276
- const key = a.slice(2);
419
+ const raw = a.slice(2);
420
+ const eq = raw.indexOf("=");
421
+ if (eq >= 0) {
422
+ // D44: accept --key=value as well as --key value (issue #7 — the
423
+ // = form was silently misparsed before, dropping the flag).
424
+ out[raw.slice(0, eq)] = raw.slice(eq + 1);
425
+ continue;
426
+ }
277
427
  const val = argv[i + 1];
278
428
  if (val !== undefined && !val.startsWith("--")) {
279
- out[key] = val;
429
+ out[raw] = val;
280
430
  i++;
281
431
  } else {
282
- out[key] = "true";
432
+ out[raw] = "true";
283
433
  }
284
434
  }
285
435
  }
@@ -409,7 +559,14 @@ async function main() {
409
559
  if (cmd === "migrate" && rest.includes("--to-v2")) {
410
560
  const flags = parseFlags(rest);
411
561
  const dryRun = flags["dry-run"] === "true";
412
- const stats = migrateV2({ dryRun });
562
+ let stats;
563
+ try {
564
+ stats = migrateV2({ dryRun });
565
+ } catch (err) {
566
+ // D44: actionable message, not a raw syscall stack (issue #7).
567
+ console.error(`Error: ${(err as Error).message}`);
568
+ process.exit(1);
569
+ }
413
570
  console.log(
414
571
  `${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
415
572
  `${stats.converted} to convert, ${stats.skippedV2} already v2`,
@@ -423,7 +580,9 @@ async function main() {
423
580
  }
424
581
  if (stats.legacyBackup) {
425
582
  console.log(
426
- `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
583
+ dryRun
584
+ ? `\nDRY RUN: legacy my-o-memory data dir found — it would be merged and backed up at:\n ${stats.legacyBackup}`
585
+ : `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
427
586
  );
428
587
  }
429
588
  if (!dryRun && stats.converted > 0) {
@@ -483,7 +642,9 @@ async function main() {
483
642
  }
484
643
  try {
485
644
  const saved = validate(value);
486
- const file = saveConfig({ [key!]: saved });
645
+ // Dotted keys (e.g. sync.autoPull) write into the nested config
646
+ // object, preserving sibling keys already on disk.
647
+ const file = saveConfig(setConfigPath(key!, saved));
487
648
  console.log(`set ${key} = ${JSON.stringify(saved)} (${file})`);
488
649
  } catch (err) {
489
650
  console.error(`invalid value for ${key}: ${(err as Error).message}`);
@@ -855,6 +1016,139 @@ function positionalArgs(argv: string[]): string[] {
855
1016
  return;
856
1017
  }
857
1018
 
1019
+ // §9 / D12: explicit pull — fetch + fast-forward only, never auto-merge.
1020
+ if (cmd === "pull") {
1021
+ const { GitProvider } = await import("./providers/git.ts");
1022
+ const root = projectRoot();
1023
+ try {
1024
+ const r = new GitProvider().pull(root);
1025
+ const stats = syncScope(project.key, "pull");
1026
+ if (r.fastForwarded) {
1027
+ console.log(
1028
+ `pulled ${r.branch} from ${r.remote}: ${r.before.slice(0, 8)} → ${r.after.slice(0, 8)} (fast-forward)`,
1029
+ );
1030
+ } else {
1031
+ console.log(`already up to date: ${r.branch} @ ${r.after.slice(0, 8)}`);
1032
+ }
1033
+ console.log(
1034
+ `index: +${stats.added} ~${stats.updated} -${stats.removed} (scanned ${stats.scanned})`,
1035
+ );
1036
+ } catch (e) {
1037
+ console.error(`pull failed: ${(e as Error).message}`);
1038
+ process.exit(2);
1039
+ }
1040
+ return;
1041
+ }
1042
+
1043
+ // Explicit push — open-memex never pushes on its own (D36).
1044
+ if (cmd === "push") {
1045
+ const { GitProvider } = await import("./providers/git.ts");
1046
+ const root = projectRoot();
1047
+ try {
1048
+ const r = new GitProvider().push(root);
1049
+ syncScope(project.key, "push");
1050
+ console.log(`pushed ${r.branch} to ${r.remote} @ ${r.head.slice(0, 8)}`);
1051
+ } catch (e) {
1052
+ console.error(`push failed: ${(e as Error).message}`);
1053
+ process.exit(2);
1054
+ }
1055
+ return;
1056
+ }
1057
+
1058
+ // §9 / D40: portable export bundle (markdown + manifest).
1059
+ if (cmd === "export") {
1060
+ const { exportMemories } = await import("./export.ts");
1061
+ const flags = parseFlags(rest);
1062
+ const all = flags["all"] === "true" || flags["a"] === "true" || rest.includes("--all") || rest.includes("-a");
1063
+ const scopeFlag = flags["scope"] ?? "project";
1064
+ // parseFlags only handles `--` flags; `-o <file>` is picked up here.
1065
+ const oIdx = rest.findIndex((a) => a === "-o");
1066
+ const outFile = flags["o"] ?? flags["output"] ?? (oIdx >= 0 ? rest[oIdx + 1] : undefined);
1067
+ const scopeKeys =
1068
+ scopeFlag === "both"
1069
+ ? [project.key, PERSONAL_SCOPE.key]
1070
+ : scopeFlag === "personal"
1071
+ ? [PERSONAL_SCOPE.key]
1072
+ : [project.key];
1073
+ try {
1074
+ const r = exportMemories({
1075
+ scopeKeys,
1076
+ type: flags["type"],
1077
+ tag: flags["tag"],
1078
+ includePrivate: all,
1079
+ outFile,
1080
+ });
1081
+ console.log(`exported ${r.exported} memories → ${r.file}`);
1082
+ if (!r.includePrivate && r.skippedPrivate > 0) {
1083
+ console.log(`skipped ${r.skippedPrivate} private memories (use --all to include them)`);
1084
+ }
1085
+ } catch (e) {
1086
+ console.error(`export failed: ${(e as Error).message}`);
1087
+ process.exit(2);
1088
+ }
1089
+ return;
1090
+ }
1091
+
1092
+ if (cmd === "import") {
1093
+ const { importBundle } = await import("./export.ts");
1094
+ const flags = parseFlags(rest);
1095
+ const bundle = positionalArgs(rest)[0];
1096
+ if (!bundle) {
1097
+ console.error(`usage: open-memex import <bundle.tar.gz> [--dry-run]`);
1098
+ process.exit(2);
1099
+ }
1100
+ const dryRun = flags["dry-run"] === "true";
1101
+ try {
1102
+ const r = importBundle(bundle, { projectScopeKey: project.key, dryRun });
1103
+ if (!dryRun) {
1104
+ syncScope(project.key, "cli");
1105
+ syncScope(PERSONAL_SCOPE.key, "cli");
1106
+ }
1107
+ console.log(
1108
+ `${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`,
1109
+ );
1110
+ for (const c of r.skippedConflict) {
1111
+ console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
1112
+ }
1113
+ } catch (e) {
1114
+ console.error(`import failed: ${(e as Error).message}`);
1115
+ process.exit(2);
1116
+ }
1117
+ return;
1118
+ }
1119
+
1120
+ // Phase 3: distill project memories into a proposed AGENTS.md snippet.
1121
+ if (cmd === "distill-agents") {
1122
+ const { distillAgentsMarkdown } = await import("./distill-agents.ts");
1123
+ const flags = parseFlags(rest);
1124
+ const scopeFlag = flags["scope"] ?? "project";
1125
+ const scopeKeys =
1126
+ scopeFlag === "personal" ? [PERSONAL_SCOPE.key] : [project.key];
1127
+ const types = flags["type"]
1128
+ ? flags["type"].split(",").map((t) => t.trim()).filter(Boolean)
1129
+ : undefined;
1130
+ const limit = flags["limit"] ? parseInt(flags["limit"], 10) : undefined;
1131
+ const oIdx = rest.findIndex((a) => a === "-o");
1132
+ const outFile = oIdx >= 0 ? rest[oIdx + 1] : undefined;
1133
+ try {
1134
+ const md = distillAgentsMarkdown({ scopeKeys, types, limit });
1135
+ if (!md) {
1136
+ console.log("no distillable memories found (decisions, constraints, lessons, gotchas, howtos)");
1137
+ return;
1138
+ }
1139
+ if (outFile) {
1140
+ fs.writeFileSync(outFile, md, "utf8");
1141
+ console.log(`wrote proposed AGENTS.md snippet → ${outFile} (review and merge by hand)`);
1142
+ } else {
1143
+ console.log(md);
1144
+ }
1145
+ } catch (e) {
1146
+ console.error(`distill-agents failed: ${(e as Error).message}`);
1147
+ process.exit(2);
1148
+ }
1149
+ return;
1150
+ }
1151
+
858
1152
  if (cmd === "pr-status") {
859
1153
  const flags = parseFlags(rest);
860
1154
  try {
@@ -923,6 +1217,10 @@ function positionalArgs(argv: string[]): string[] {
923
1217
  entries.push({ key: name, files, marker });
924
1218
  }
925
1219
  entries.sort((a, b) => b.files - a.files);
1220
+ if (entries.length === 0) {
1221
+ console.log("(no scopes with memories yet — add one with `open-memex add`)");
1222
+ return;
1223
+ }
926
1224
  for (const e of entries) {
927
1225
  console.log(` ${e.files.toString().padStart(4)} ${e.key}${e.marker}`);
928
1226
  }
package/src/config.ts CHANGED
@@ -19,6 +19,14 @@ export interface MyOMemoryConfig {
19
19
  * memories can never escape the repo.
20
20
  */
21
21
  memoryDir: string;
22
+ /**
23
+ * Sync behavior (§9, D12). Pulls are explicit by default; session start
24
+ * never touches the network unless autoPull is true — and even then a
25
+ * failed pull never blocks the session.
26
+ */
27
+ sync: {
28
+ autoPull: boolean;
29
+ };
22
30
  }
23
31
 
24
32
  export const DEFAULT_CONFIG: MyOMemoryConfig = {
@@ -54,6 +62,7 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
54
62
  redactPatterns: [],
55
63
  logLevel: "info",
56
64
  memoryDir: ".ai/open-memex",
65
+ sync: { autoPull: false },
57
66
  };
58
67
 
59
68
  function stripJsonComments(raw: string): string {
@@ -102,6 +111,7 @@ export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
102
111
  injectOnFirstTurn: toBool,
103
112
  keywordCaptureEnabled: toBool,
104
113
  memoryDir: toRelativeDir,
114
+ "sync.autoPull": toBool,
105
115
  logLevel: (v) => {
106
116
  if (v !== "info" && v !== "debug") throw new Error('must be "info" or "debug"');
107
117
  return v;
@@ -0,0 +1,85 @@
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.ts";
10
+
11
+ const DEFAULT_TYPES = ["decision", "constraint", "lesson", "gotcha", "howto"];
12
+
13
+ export interface DistillAgentsOptions {
14
+ scopeKeys: string[];
15
+ types?: string[];
16
+ limit?: number;
17
+ }
18
+
19
+ interface Row {
20
+ id: string;
21
+ type: string;
22
+ tags: string;
23
+ content: string;
24
+ updated_at: number;
25
+ }
26
+
27
+ export function distillAgentsMarkdown(opts: DistillAgentsOptions): string {
28
+ const types = opts.types?.length ? opts.types : DEFAULT_TYPES;
29
+ const limit = Math.max(1, Math.min(opts.limit ?? 30, 200));
30
+ const sql = `
31
+ SELECT id, type, tags, content, updated_at FROM memories
32
+ WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})
33
+ AND type IN (${types.map(() => "?").join(",")})
34
+ AND status = 'active'
35
+ ORDER BY updated_at DESC
36
+ LIMIT ?`;
37
+ const rows = db()
38
+ .prepare(sql)
39
+ .all(...(opts.scopeKeys as any[]), ...(types as any[]), limit) as Row[];
40
+
41
+ if (rows.length === 0) return "";
42
+ const date = new Date().toISOString().slice(0, 10);
43
+ const lines: string[] = [
44
+ `## Learned (distilled from open-memex, ${date})`,
45
+ ``,
46
+ `<!-- Proposed by \`open-memex distill-agents\`. Review each line, keep`,
47
+ `what's true, delete the rest, then merge into this file by hand. -->`,
48
+ ``,
49
+ ];
50
+ // Group by type, preserving recency within each group.
51
+ const byType = new Map<string, Row[]>();
52
+ for (const r of rows) {
53
+ const g = byType.get(r.type) ?? [];
54
+ g.push(r);
55
+ byType.set(r.type, g);
56
+ }
57
+ for (const t of types) {
58
+ const g = byType.get(t);
59
+ if (!g?.length) continue;
60
+ lines.push(`### ${t}`);
61
+ lines.push(``);
62
+ for (const r of g) {
63
+ const first = r.content.split("\n").map((l) => l.trim()).find((l) => l) ?? "";
64
+ const snippet = first.length > 160 ? first.slice(0, 160) + "…" : first;
65
+ lines.push(`- ${snippet} \`[${r.id.slice(0, 8)}]\``);
66
+ }
67
+ lines.push(``);
68
+ }
69
+ // D43 — §3.5 memory-hygiene footer (double insurance for opencode users,
70
+ // who never see the MCP handshake / init instructions): teach the agent
71
+ // reading this AGENTS.md to propose distilled captures at checkpoints.
72
+ lines.push(`### Memory hygiene (open-memex)`);
73
+ lines.push(``);
74
+ lines.push(
75
+ `- At checkpoints (session start, end of a work chunk, after the user commits),`,
76
+ ` distill the session: propose 1–3 short memories capturing the useful`,
77
+ ` conclusion — what was learned or decided, how an issue was resolved, what`,
78
+ ` to avoid, where the authoritative doc lives — not the raw transcript.`,
79
+ ` Save nothing without user approval.`,
80
+ `- If the knowledge already lives in project docs, save a \`reference\` memory`,
81
+ ` pointing at the doc instead of copying it.`,
82
+ );
83
+ lines.push(``);
84
+ return lines.join("\n");
85
+ }