open-memex 0.7.0-alpha.8 → 0.7.0-alpha.9

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 CHANGED
@@ -33,6 +33,7 @@ npm run typecheck # tsc --noEmit — the only
33
33
  npm run cli -- where | list | search "q" | add ... | forget <id> | reindex
34
34
  npm run cli -- <command> --help # per-command help (AI assistants discover flags this way)
35
35
  npm run cli -- sync-status # last sync time/kind + outbox drafts + repo review states + uncommitted files
36
+ npm run cli -- inventory [--format text|json] [--scope both] # what is remembered, in the open (D68); personal-bearing output refused inside a git worktree without --allow-personal
36
37
  npm run cli -- audit [--scope project|personal|both] # memory health: near-dup pairs, stale actives, broken chains, personal-in-repo (read-only)
37
38
  npm run cli -- pull # fetch + fast-forward only (explicit; diverged = clean failure, never force-merge)
38
39
  npm run cli -- push # push current branch to remote (explicit only; open-memex never auto-pushes)
@@ -165,9 +166,9 @@ Context injection happens exactly once per session in `experimental.chat.system.
165
166
 
166
167
  ## Design constraints — read the frozen design first
167
168
 
168
- `docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions D1–D64 settled; open questions tracked at the end of the doc). Per its §12:
169
+ `docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions settled through D68; open questions tracked at the end of the doc). Per its §12:
169
170
  AGENTS.md answers "how should AI work here"; the design doc answers "why is it
170
- built this way" (principles, iron rules, the append-only decision log, currently D1–D67). Before changing
171
+ built this way" (principles, iron rules, the append-only decision log, currently D1–D68). Before changing
171
172
  architecture, scope semantics, lifecycle, or the protocol surface (frontmatter
172
173
  schema, MCP tools, CLI contract), read the relevant design section — the decision
173
174
  log records what was already considered and rejected.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,15 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## Memory visibility: see it, hide it, delete it (D68)
13
+
14
+ Group 3-C — the surface a user reads to learn what the store actually remembers:
15
+
16
+ - **`memory_list` is now a numbered inventory.** Lines are structured (`[type] id=… created=… source=…`), keep the raw provenance, and every listing states its true total — a truncated list says how many entries it is not showing instead of quietly looking complete. New input `include: "active" | "all"` (default `active`): **behavior change** — superseded versions, retracted and archived memories no longer appear unless you pass `include=all` (the audit view). The CLI gains the matching `list --include` flag.
17
+ - **`open-memex inventory`** renders the same data as readable text (default) or JSON (`--format json`, format `open-memex-inventory/1`) — personal plus the current project, outbox drafts in their own section, hidden history counted rather than silently dropped. A report containing personal memories refuses to be written inside a git working tree without `--allow-personal`.
18
+ - **Hide instead of delete:** `memory_forget` gains `soft=true` (CLI: `forget --soft`) — the memory is retracted: out of lists and search, file kept. Still one-way (D64): bringing the fact back means saving it again.
19
+ - **Guidance kept in one voice:** the tool descriptions, the MCP session-start instructions, SKILL.md and the `distill-agents` AGENTS.md snippet all teach the same flow — answer "what do you remember?" conversationally from the inventory, re-list before acting on a number, read an entry back before deleting it.
20
+
12
21
  ## Keyword capture: what counts as a trigger (D67)
13
22
 
14
23
  The D66 review left two questions open and introduced one regression. Ruled, not deferred:
package/README.md CHANGED
@@ -416,9 +416,9 @@ they only matter once you share memories through Git.
416
416
  |---|---|
417
417
  | ● `memory_add` | Save a fact, preference, decision, note |
418
418
  | ● `memory_search` | Keyword search (BM25) across project + personal memories |
419
- | ● `memory_list` | List memories in a scope (`project`, `personal`, or `both`), newest first |
419
+ | ● `memory_list` | List memories in a scope (`project`, `personal`, or `both`) as a numbered inventory; `include=all` is the audit view |
420
420
  | ● `memory_supersede` | Replace a memory with a newer version (keeps a supersede chain) |
421
- | ● `memory_forget` | Delete a memory by id |
421
+ | ● `memory_forget` | Delete a memory by id (`soft=true` hides it instead — retracted, one-way) |
422
422
  | `memory_status` | Show the sync queue: outbox drafts, repo review states, uncommitted files |
423
423
  | `memory_submit` | Move named drafts into the repo memory dir (local branch + commit) |
424
424
  | `memory_propose` | Copy personal memories into the project scope as review candidates |
@@ -643,9 +643,17 @@ open-memex search "auth flow"
643
643
  open-memex search "auth flow" --explain # show FTS expression, scores, lifecycle-hidden counts
644
644
  open-memex list --scope project
645
645
  open-memex list --scope both # each scope's newest under its own header
646
+ open-memex list --include all # audit view: superseded versions, retracted, archived
646
647
  open-memex supersede <id> "Updated content"
647
648
  open-memex status <id> deprecated
648
649
  open-memex forget <id>
650
+ open-memex forget <id> --soft # hide instead of delete (retracted; one-way)
651
+
652
+ open-memex inventory # everything remembered, as readable text
653
+ open-memex inventory --format json # the same data, for agents
654
+ # Personal + current project, outbox drafts in their own section, hidden
655
+ # history counted. Refuses to write a personal-bearing report inside a
656
+ # git working tree unless you pass --allow-personal.
649
657
  ```
650
658
 
651
659
  Team review workflow (two homes, one per stage):
package/README.zh-CN.md CHANGED
@@ -275,9 +275,9 @@ MCP 服务器暴露十一个工具;opencode 原生插件暴露其中五个核
275
275
  |---|---|
276
276
  | ● `memory_add` | 保存一条事实、偏好、决策、笔记 |
277
277
  | ● `memory_search` | 关键词搜索(BM25),横跨项目 + 个人记忆 |
278
- | ● `memory_list` | 列出某个作用域(`project`、`personal` 或 `both`)的记忆,最新在前 |
278
+ | ● `memory_list` | 以编号清单列出某个作用域(`project`、`personal` 或 `both`)的记忆;`include=all` 是审计视图 |
279
279
  | ● `memory_supersede` | 用新版本替换一条记忆(保留 supersede 链) |
280
- | ● `memory_forget` | 按 id 删除一条记忆 |
280
+ | ● `memory_forget` | 按 id 删除一条记忆(`soft=true` 改为隐藏——retracted,单向) |
281
281
  | `memory_status` | 显示同步队列:outbox 草稿、仓库评审状态、未提交文件 |
282
282
  | `memory_submit` | 把指定的草稿移进仓库记忆目录(本地分支 + 提交) |
283
283
  | `memory_propose` | 把个人记忆复制到项目作用域,作为评审候选 |
@@ -443,9 +443,16 @@ open-memex search "auth flow"
443
443
  open-memex search "auth flow" --explain # 显示 FTS 表达式、分数、被生命周期藏掉的计数
444
444
  open-memex list --scope project
445
445
  open-memex list --scope both # 两个作用域各自最新的,各带一个标题
446
+ open-memex list --include all # 审计视图:被替换的版本、已撤回、已归档
446
447
  open-memex supersede <id> "Updated content"
447
448
  open-memex status <id> deprecated
448
449
  open-memex forget <id>
450
+ open-memex forget <id> --soft # 隐藏而不是删除(retracted,单向)
451
+
452
+ open-memex inventory # 记住的一切,纯文本清单
453
+ open-memex inventory --format json # 同一份数据,JSON 格式给 agent 用
454
+ # 个人 + 当前项目,outbox 草稿单独成节,被藏起的历史只计数。含个人内容的
455
+ # 报告拒绝写进 git 工作树,除非显式 --allow-personal。
449
456
  ```
450
457
 
451
458
  团队评审工作流(两个家,一个阶段一个):
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- f4cf4e253bbf0f3254ec439b1344af50e88cc4fd068b04be73cdc20ee770e819
1
+ 3a590ae176c625e174a075a2d9068fe443d23c374f70fc7df76809ff10a63ad4
package/dist/cli.js CHANGED
@@ -17,7 +17,22 @@ import { redact } from "./redact.js";
17
17
  import { resolveMcpCommand } from "./init.js";
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
+ import { execFileSync } from "node:child_process";
20
21
  import { fileURLToPath } from "node:url";
22
+ /** True when `target` sits inside a git working tree (D68 guard). */
23
+ function isInsideWorkTree(target) {
24
+ try {
25
+ const dir = path.dirname(path.resolve(target));
26
+ const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
27
+ stdio: ["ignore", "pipe", "ignore"],
28
+ encoding: "utf8",
29
+ });
30
+ return out.trim() === "true";
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
21
36
  /** Per-command help, printed by `open-memex <command> --help`.
22
37
  AI assistants discover the CLI through --help, so every command needs one. */
23
38
  const COMMAND_HELP = {
@@ -29,12 +44,14 @@ Example:
29
44
  open-memex where`,
30
45
  list: `List memories in a scope, newest first.
31
46
 
32
- Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N]
47
+ Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
33
48
 
34
49
  Flags:
35
- --scope project (default), personal, or both
36
- --type filter by memory type
37
- --limit max results
50
+ --scope project (default), personal, or both
51
+ --type filter by memory type
52
+ --limit max results
53
+ --include active (default: what is currently remembered) or all (audit
54
+ view: superseded versions, retracted and archived included)
38
55
 
39
56
  Examples:
40
57
  open-memex list
@@ -85,12 +102,15 @@ Retraction is one-way: a retracted memory cannot be set back to active
85
102
 
86
103
  Example:
87
104
  open-memex status 01ABC deprecated`,
88
- forget: `Delete a memory by id.
105
+ forget: `Delete a memory by id. With --soft, hide it instead: the memory
106
+ is retracted (out of lists and search, file kept). Retraction is one-way —
107
+ save the content as a new memory to bring it back.
89
108
 
90
- Usage: open-memex forget <id>
109
+ Usage: open-memex forget <id> [--soft]
91
110
 
92
111
  Example:
93
- open-memex forget 01ABC`,
112
+ open-memex forget 01ABC
113
+ open-memex forget 01ABC --soft`,
94
114
  propose: `Copy personal memories into the project outbox as review drafts.
95
115
  The personal originals stay put. Nothing enters git at this step.
96
116
 
@@ -137,6 +157,19 @@ Usage: open-memex sync-status
137
157
 
138
158
  Example:
139
159
  open-memex sync-status`,
160
+ inventory: `Show everything remembered — personal + this project — in the
161
+ open: what each memory says, where it came from, and whether it has reached
162
+ the repo yet. Read-only; deleting stays a confirmed conversation with your
163
+ agent. Formats: text (default, diffable) and json (for agents).
164
+
165
+ Usage: open-memex inventory [--format text|json] [--scope project|personal|both] [--out <path>] [--allow-personal]
166
+
167
+ Default scope: both. Without --out the report goes to stdout. A report that
168
+ includes personal memories is refused inside a git working tree unless
169
+ --allow-personal is passed — personal content must not ride a commit.
170
+
171
+ Example:
172
+ open-memex inventory --format json --out ~/inventory.json`,
140
173
  pull: `Pull shared project memories from the git remote: fetch + fast-forward
141
174
  only. Never auto-merges — a diverged branch fails with a clear message and is
142
175
  left for you to resolve by hand. On success the local index re-syncs.
@@ -334,16 +367,17 @@ function usage(exitCode = 1) {
334
367
 
335
368
  Usage:
336
369
  open-memex where
337
- open-memex list [--scope project|personal] [--type T] [--limit N]
370
+ open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
338
371
  open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
339
372
  open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
340
373
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
341
374
  open-memex status <id> active|deprecated|retracted|archived
342
- open-memex forget <id>
375
+ open-memex forget <id> [--soft]
343
376
  open-memex propose <id...> --to project [--local-approve]
344
377
  open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
345
378
  open-memex resolve [id-or-path]
346
379
  open-memex sync-status
380
+ open-memex inventory [--format text|json] [--scope project|personal|both] [--out <path>] [--allow-personal]
347
381
  open-memex pull
348
382
  open-memex push
349
383
  open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
@@ -784,6 +818,7 @@ async function main() {
784
818
  const hits = list(s.key, {
785
819
  type: flags.type,
786
820
  limit: flags.limit ? Number(flags.limit) : undefined,
821
+ include: flags.include === "all" ? "all" : "active",
787
822
  });
788
823
  if (scopes.length > 1)
789
824
  console.log(`## ${s.kind} (${s.key})`);
@@ -792,7 +827,7 @@ async function main() {
792
827
  continue;
793
828
  }
794
829
  for (const h of hits) {
795
- const dep = h.status === "deprecated" ? " [deprecated]" : "";
830
+ const dep = h.status !== "active" ? ` [${h.status}]` : "";
796
831
  const rev = hitStateLabel(h);
797
832
  console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
798
833
  }
@@ -964,6 +999,20 @@ async function main() {
964
999
  const id = rest[0];
965
1000
  if (!id)
966
1001
  usage();
1002
+ // D68: --soft hides (retracts) instead of deleting — same action as
1003
+ // the memory_forget tool's soft flag. One-way (D64).
1004
+ if (rest.includes("--soft")) {
1005
+ try {
1006
+ const mf = setStatus(id, "retracted");
1007
+ upsertFromFile(mf);
1008
+ console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
1009
+ }
1010
+ catch (e) {
1011
+ console.error(`hide failed: ${e.message}`);
1012
+ process.exit(1);
1013
+ }
1014
+ return;
1015
+ }
967
1016
  const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
968
1017
  if (!row) {
969
1018
  console.error("not found");
@@ -1101,6 +1150,52 @@ async function main() {
1101
1150
  }
1102
1151
  return;
1103
1152
  }
1153
+ if (cmd === "inventory") {
1154
+ // D68: refresh both scopes first so the report matches the markdown
1155
+ // on disk. Default scope: personal + this project.
1156
+ syncScope(project.key, "cli");
1157
+ syncScope(PERSONAL_SCOPE.key, "cli");
1158
+ const flags = parseFlags(rest);
1159
+ const format = flags.format ?? "text";
1160
+ if (format !== "text" && format !== "json") {
1161
+ console.error(`inventory: unknown --format '${format}' (text|json; html arrives separately)`);
1162
+ process.exit(2);
1163
+ }
1164
+ const scopeKeys = flags.scope === "personal"
1165
+ ? [PERSONAL_SCOPE.key]
1166
+ : flags.scope === "project"
1167
+ ? [project.key]
1168
+ : [PERSONAL_SCOPE.key, project.key];
1169
+ const outPath = flags["out"] ?? flags["output"];
1170
+ // D68/3-C v2: the report contains personal content. Writing it into
1171
+ // a git working tree would ride the next commit off this machine —
1172
+ // refuse unless the user explicitly accepts that.
1173
+ if (outPath &&
1174
+ scopeKeys.includes(PERSONAL_SCOPE.key) &&
1175
+ flags["allow-personal"] !== "true" &&
1176
+ isInsideWorkTree(outPath)) {
1177
+ console.error(`inventory: ${outPath} is inside a git working tree and this report includes personal memories. ` +
1178
+ `Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`);
1179
+ process.exit(2);
1180
+ }
1181
+ try {
1182
+ const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.js");
1183
+ const data = loadInventory(scopeKeys);
1184
+ const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
1185
+ if (outPath) {
1186
+ fs.writeFileSync(outPath, rendered, "utf8");
1187
+ console.log(`inventory: ${data.total} memories → ${outPath}`);
1188
+ }
1189
+ else {
1190
+ process.stdout.write(rendered);
1191
+ }
1192
+ }
1193
+ catch (e) {
1194
+ console.error(`inventory failed: ${e.message}`);
1195
+ process.exit(2);
1196
+ }
1197
+ return;
1198
+ }
1104
1199
  if (cmd === "sync-status") {
1105
1200
  syncScope(project.key, "cli");
1106
1201
  syncScope(PERSONAL_SCOPE.key, "cli");
@@ -62,7 +62,10 @@ export function distillAgentsMarkdown(opts) {
62
62
  // inferred captures source: "inference"; this footer omitted it, so
63
63
  // opencode agents saved distilled conclusions as if the user had
64
64
  // stated them ("tool"). Keep the rubric one voice across surfaces.
65
- `- Mark captures you infer (rather than the user stating) with`, ` \`source: "inference"\` — provenance matters more than polish.`);
65
+ `- Mark captures you infer (rather than the user stating) with`, ` \`source: "inference"\` — provenance matters more than polish.`,
66
+ // D68: same voice for the visibility surface — opencode agents reading
67
+ // this footer never see the MCP handshake either.
68
+ `- When the user asks what you remember, list it conversationally`, ` (\`memory_list\` with scope both, or \`open-memex inventory\`) — plain`, ` words, not raw ids. Before deleting an entry they point at, read it`, ` back in full and confirm; deletion is permanent. Offer hiding`, ` (\`memory_forget\` soft / \`forget --soft\`) when they hesitate.`);
66
69
  lines.push(``);
67
70
  return lines.join("\n");
68
71
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `open-memex inventory` (D68): what the store remembers, in the open.
3
+ * One data layer, three renderers (text / json / html). The HTML
4
+ * renderer lives in inventory-html.ts; this module owns the data and
5
+ * the two renderers an agent or a terminal consumes directly.
6
+ *
7
+ * Design rules (3-C v2) enforced here:
8
+ * - Default scope is personal + the current project — "can't see it"
9
+ * is the disease this surface treats.
10
+ * - Nothing truncates silently: every section states its totals, and
11
+ * hidden history (superseded versions, retracted, archived) is
12
+ * counted and pointed at, never just omitted.
13
+ * - Drafts that have not reached the repo (the outbox) are their own
14
+ * section, consistent with what memory_status reports.
15
+ * - The report is read-only. Deleting stays a confirmed conversation.
16
+ */
17
+ import { db } from "./store/db.js";
18
+ import { PERSONAL_SCOPE } from "./scope.js";
19
+ import { formatInventoryLine } from "./retrieve/display.js";
20
+ const BASE_COLS = `id, scope_key, project_name, type, status, review_state,
21
+ source, created_at, updated_at, content, file_path`;
22
+ /**
23
+ * Load the inventory for the given scopes. `projectScopeKey` separates
24
+ * the outbox drafts (they belong to that scope's draft rows).
25
+ */
26
+ export function loadInventory(scopeKeys) {
27
+ const inList = scopeKeys.map(() => "?").join(",");
28
+ const current = db()
29
+ .prepare(`SELECT ${BASE_COLS} FROM memories
30
+ WHERE scope_key IN (${inList}) AND status IN ('active', 'deprecated')
31
+ ORDER BY scope_key, updated_at DESC`)
32
+ .all(...scopeKeys);
33
+ const hiddenRows = db()
34
+ .prepare(`SELECT status, COUNT(*) AS n FROM memories
35
+ WHERE scope_key IN (${inList}) AND status IN ('superseded', 'retracted', 'archived')
36
+ GROUP BY status`)
37
+ .all(...scopeKeys);
38
+ const hidden = { superseded: 0, retracted: 0, archived: 0 };
39
+ for (const r of hiddenRows) {
40
+ if (r.status === "superseded")
41
+ hidden.superseded = r.n;
42
+ if (r.status === "retracted")
43
+ hidden.retracted = r.n;
44
+ if (r.status === "archived")
45
+ hidden.archived = r.n;
46
+ }
47
+ const outbox = current.filter((r) => r.scope_key !== PERSONAL_SCOPE.key && r.review_state === "draft");
48
+ const outboxIds = new Set(outbox.map((r) => r.id));
49
+ const byScope = new Map();
50
+ for (const r of current) {
51
+ if (outboxIds.has(r.id))
52
+ continue;
53
+ const arr = byScope.get(r.scope_key) ?? [];
54
+ arr.push(r);
55
+ byScope.set(r.scope_key, arr);
56
+ }
57
+ // Personal first, then project scopes by key for a stable order.
58
+ const orderedKeys = [
59
+ ...scopeKeys.filter((k) => k === PERSONAL_SCOPE.key),
60
+ ...[...byScope.keys()].filter((k) => k !== PERSONAL_SCOPE.key).sort(),
61
+ ];
62
+ const scopes = orderedKeys
63
+ .filter((k) => byScope.has(k))
64
+ .map((k) => {
65
+ const rows = byScope.get(k);
66
+ return {
67
+ scopeKey: k,
68
+ projectName: rows[0]?.project_name || k,
69
+ rows,
70
+ };
71
+ });
72
+ return {
73
+ generatedAt: new Date().toISOString(),
74
+ scopes,
75
+ outbox,
76
+ hidden,
77
+ total: current.length,
78
+ };
79
+ }
80
+ function hiddenLine(data) {
81
+ const { superseded, retracted, archived } = data.hidden;
82
+ if (superseded + retracted + archived === 0)
83
+ return "";
84
+ return `Not shown: ${superseded} superseded version(s), ${retracted} retracted, ${archived} archived — inspect with \`open-memex list --include all\`.`;
85
+ }
86
+ function entriesText(rows) {
87
+ return rows
88
+ .map((r, i) => formatInventoryLine(i, { ...r, snippet: r.content }, PERSONAL_SCOPE.key))
89
+ .join("\n");
90
+ }
91
+ /** Plain-text rendering: diffable in any terminal or CI log. */
92
+ export function renderInventoryText(data) {
93
+ const out = [];
94
+ out.push(`What OpenMemex remembers — ${data.generatedAt}`);
95
+ out.push(`${data.total} memories (${data.scopes.reduce((n, s) => n + (s.scopeKey === PERSONAL_SCOPE.key ? s.rows.length : 0), 0)} personal, ${data.outbox.length} in the outbox).`);
96
+ for (const s of data.scopes) {
97
+ const title = s.scopeKey === PERSONAL_SCOPE.key
98
+ ? "About you — personal (never leaves this machine)"
99
+ : `Project: ${s.projectName}`;
100
+ out.push("", `## ${title} (${s.rows.length})`, entriesText(s.rows));
101
+ }
102
+ if (data.outbox.length > 0) {
103
+ out.push("", `## Not in the repo yet — outbox drafts (${data.outbox.length})`, "These exist only on this machine. They reach the repo through review (submit → promote), never automatically.", entriesText(data.outbox));
104
+ }
105
+ const hidden = hiddenLine(data);
106
+ if (hidden)
107
+ out.push("", hidden);
108
+ out.push("", "Read-only report. To remove a memory, tell your agent to forget it — it will read the entry back before deleting (deletion is permanent).");
109
+ return out.join("\n") + "\n";
110
+ }
111
+ /** JSON rendering: same data, for agents. Mirrors the export field names. */
112
+ export function renderInventoryJson(data) {
113
+ const entry = (r) => ({
114
+ id: r.id,
115
+ scope: r.scope_key,
116
+ project: r.project_name,
117
+ type: r.type,
118
+ status: r.status,
119
+ review_state: r.review_state,
120
+ source: r.source,
121
+ created_at: new Date(r.created_at).toISOString(),
122
+ updated_at: new Date(r.updated_at).toISOString(),
123
+ content: r.content,
124
+ file: r.file_path,
125
+ });
126
+ return (JSON.stringify({
127
+ format: "open-memex-inventory/1",
128
+ generated_at: data.generatedAt,
129
+ total: data.total,
130
+ scopes: data.scopes.map((s) => ({
131
+ scope: s.scopeKey,
132
+ project: s.projectName,
133
+ memories: s.rows.map(entry),
134
+ })),
135
+ outbox: data.outbox.map(entry),
136
+ hidden: data.hidden,
137
+ }, null, 2) + "\n");
138
+ }
package/dist/mcp.js CHANGED
@@ -89,6 +89,15 @@ memory_resolve, memory_pr_status — always call them by these full names.
89
89
  question into its concepts and try 2–3 phrasings per concept (synonyms, the
90
90
  user's other language, shorter keyword forms), and check the other scope too,
91
91
  before concluding nothing is stored.
92
+ - When the user asks what you remember (about them, or in this project),
93
+ call memory_list with scope=both and present it conversationally — turn
94
+ the structured fields into plain words (source=user → "you told me
95
+ this"; source=inference → "I inferred this, check me"), and don't read
96
+ raw ids aloud. If they ask to remove or hide an entry, re-run
97
+ memory_list first (numbers from an older listing may have shifted),
98
+ read the candidate back in full, and get a confirmation before calling
99
+ memory_forget: deletion is permanent. If they hesitate, offer soft=true
100
+ (hide it instead — still one-way, but the file survives).
92
101
  - Memories default to this project's scope; use the personal scope for facts
93
102
  about the user that hold across all projects. When a saved fact becomes
94
103
  outdated, call memory_supersede (find the old memory's id with memory_search
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Display helpers (D68): how memory provenance, age, and state are
3
+ * rendered on the user-facing surfaces — the `memory_list` inventory an
4
+ * agent shows its user, and the `open-memex inventory` reports.
5
+ *
6
+ * Two rules from the 3-C design (v2) live here:
7
+ * - The list format is STRUCTURED data (number + id + created= + source=
8
+ * + state tags). The plain-language renderings ("you said this",
9
+ * "3 days ago") live in the guidance surfaces (tool descriptions,
10
+ * SKILL.md, MCP handshake) so an agent rephrases them in the
11
+ * conversation's language — the raw enums never get localized or
12
+ * reworded inside the data itself.
13
+ * - Nothing truncates silently: callers pair these lines with the true
14
+ * totals so a cut list says so.
15
+ */
16
+ /** Human age for a saved memory: "just now", "3 days ago", else a date. */
17
+ export function relativeTime(ts, now = Date.now()) {
18
+ if (!ts || Number.isNaN(ts))
19
+ return "";
20
+ const diff = now - ts;
21
+ if (diff < 0)
22
+ return "just now";
23
+ const minute = 60_000;
24
+ const hour = 60 * minute;
25
+ const day = 24 * hour;
26
+ if (diff < minute)
27
+ return "just now";
28
+ if (diff < hour) {
29
+ const n = Math.floor(diff / minute);
30
+ return `${n} minute${n === 1 ? "" : "s"} ago`;
31
+ }
32
+ if (diff < day) {
33
+ const n = Math.floor(diff / hour);
34
+ return `${n} hour${n === 1 ? "" : "s"} ago`;
35
+ }
36
+ if (diff < 30 * day) {
37
+ const n = Math.floor(diff / day);
38
+ return `${n} day${n === 1 ? "" : "s"} ago`;
39
+ }
40
+ const d = new Date(ts);
41
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
42
+ const dd = String(d.getDate()).padStart(2, "0");
43
+ return `on ${d.getFullYear()}-${mm}-${dd}`;
44
+ }
45
+ /** Compact age token for the structured list format: `3d`, `12h`, `now`. */
46
+ export function ageToken(ts, now = Date.now()) {
47
+ if (!ts || Number.isNaN(ts))
48
+ return "?";
49
+ const diff = now - ts;
50
+ if (diff < 60_000)
51
+ return "now";
52
+ if (diff < 3_600_000)
53
+ return `${Math.floor(diff / 60_000)}m`;
54
+ if (diff < 86_400_000)
55
+ return `${Math.floor(diff / 3_600_000)}h`;
56
+ if (diff < 60 * 86_400_000)
57
+ return `${Math.floor(diff / 86_400_000)}d`;
58
+ const d = new Date(ts);
59
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
60
+ const dd = String(d.getDate()).padStart(2, "0");
61
+ return `${d.getFullYear()}-${mm}-${dd}`;
62
+ }
63
+ /**
64
+ * Provenance in the user's words (D68). Used by prose surfaces (HTML
65
+ * report); the structured list keeps the raw `source=` enum instead.
66
+ * The distinction that matters to the user is *whose words these were* —
67
+ * said outright, asked to be saved, auto-captured, or inferred by the
68
+ * agent and possibly wrong.
69
+ */
70
+ export function sourceLabel(source) {
71
+ switch (source) {
72
+ case "user":
73
+ return "you said this";
74
+ case "cli":
75
+ return "you saved this from the command line";
76
+ case "tool":
77
+ return "you asked me to save this";
78
+ case "keyword":
79
+ return "auto-captured from your own wording";
80
+ case "inference":
81
+ return "I inferred this — not in your words, check me";
82
+ case "import":
83
+ return "imported from another machine";
84
+ default:
85
+ return source ? `saved (${source})` : "saved";
86
+ }
87
+ }
88
+ /**
89
+ * Review state in plain words for project scopes (D68). Personal
90
+ * memories are always `draft` by design; callers must not show this
91
+ * for them — "not yet shared" would be alarming noise there.
92
+ */
93
+ export function reviewStateLabel(reviewState) {
94
+ switch (reviewState) {
95
+ case "draft":
96
+ return "still in your outbox — not in the repo yet";
97
+ case "proposed":
98
+ return "shared with the project, waiting for review";
99
+ case "approved":
100
+ return "approved, not yet published";
101
+ case "published":
102
+ return "published to the repo";
103
+ case "rejected":
104
+ return "reviewed and rejected";
105
+ default:
106
+ return reviewState ?? "draft";
107
+ }
108
+ }
109
+ /** Escape user content for HTML. Memory text is arbitrary user input. */
110
+ export function escapeHtml(s) {
111
+ return s
112
+ .replace(/&/g, "&amp;")
113
+ .replace(/</g, "&lt;")
114
+ .replace(/>/g, "&gt;")
115
+ .replace(/"/g, "&quot;")
116
+ .replace(/'/g, "&#39;");
117
+ }
118
+ /**
119
+ * One structured inventory line (D68):
120
+ * 3. [fact] id=01K6… created=3d source=user [draft] — body text
121
+ * Number and id always appear together: the number is for the user's
122
+ * mouth ("delete #3"), the id is the machine contract. `created=` is
123
+ * when the memory was learned; `updated=` appears only when it moved
124
+ * materially later (supersede/promote touch updated_at).
125
+ */
126
+ export function formatInventoryLine(index, hit, personalKey, now = Date.now()) {
127
+ const parts = [
128
+ `${index + 1}.`,
129
+ `[${hit.type}]`,
130
+ `id=${hit.id}`,
131
+ `created=${ageToken(hit.created_at ?? hit.updated_at, now)}`,
132
+ `source=${hit.source || "?"}`,
133
+ ];
134
+ const created = hit.created_at ?? hit.updated_at ?? 0;
135
+ if (hit.updated_at && hit.updated_at - created >= 86_400_000) {
136
+ parts.push(`updated=${ageToken(hit.updated_at, now)}`);
137
+ }
138
+ if (hit.scope_key !== personalKey) {
139
+ const state = hit.review_state ?? "draft";
140
+ if (state !== "published")
141
+ parts.push(`[${state}]`);
142
+ }
143
+ if (hit.status !== "active")
144
+ parts.push(`[${hit.status}]`);
145
+ const body = hit.snippet.replace(/\s+/g, " ").trim();
146
+ return `${parts.join(" ")} — ${body}`;
147
+ }
148
+ /** Truncation disclosure (D68): a cut list always says so. */
149
+ export function truncationNote(shown, total) {
150
+ if (total <= shown)
151
+ return null;
152
+ return `… and ${total - shown} more not shown (raise limit up to 100, or use memory_search to find something specific).`;
153
+ }