open-memex 0.6.2 → 0.7.0-alpha.11

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.
Files changed (68) hide show
  1. package/AGENTS.md +25 -7
  2. package/CHANGELOG.md +103 -1
  3. package/README.md +54 -16
  4. package/README.zh-CN.md +30 -15
  5. package/dist/.build-stamp +1 -0
  6. package/dist/capture/keywords.js +41 -8
  7. package/dist/cli.js +259 -40
  8. package/dist/config.js +102 -18
  9. package/dist/distill-agents.js +9 -1
  10. package/dist/doctor.js +180 -79
  11. package/dist/export.js +12 -2
  12. package/dist/index.js +20 -112
  13. package/dist/init.js +103 -11
  14. package/dist/inventory-html.js +150 -0
  15. package/dist/inventory.js +176 -0
  16. package/dist/mcp.js +21 -5
  17. package/dist/opencode-v2.js +43 -0
  18. package/dist/paths.js +34 -2
  19. package/dist/plugin-core.js +138 -0
  20. package/dist/retrieve/display.js +161 -0
  21. package/dist/retrieve/inject.js +1 -1
  22. package/dist/retrieve/search.js +44 -5
  23. package/dist/review.js +9 -3
  24. package/dist/store/db.js +11 -9
  25. package/dist/store/lifecycle.js +20 -4
  26. package/dist/store/markdown.js +53 -7
  27. package/dist/store/migrate.js +14 -3
  28. package/dist/store/sync.js +5 -3
  29. package/dist/store/v2migrate.js +2 -2
  30. package/dist/submit.js +13 -5
  31. package/dist/tools/memory.js +28 -0
  32. package/dist/tools/ops.js +127 -16
  33. package/docs/CURATOR.md +7 -3
  34. package/docs/V2-DESIGN.md +335 -32
  35. package/docs/memory-visibility-design.md +130 -0
  36. package/package.json +2 -2
  37. package/scripts/build-stamp.mjs +63 -0
  38. package/scripts/smoke-mcp.ts +47 -1
  39. package/scripts/smoke-pure.ts +237 -3
  40. package/scripts/test-full.ts +362 -5
  41. package/skills/open-memex/SKILL.md +53 -1
  42. package/src/capture/keywords.ts +56 -6
  43. package/src/cli.ts +282 -39
  44. package/src/config.ts +104 -17
  45. package/src/distill-agents.ts +19 -0
  46. package/src/doctor.ts +181 -71
  47. package/src/export.ts +14 -2
  48. package/src/index.ts +25 -112
  49. package/src/init.ts +108 -11
  50. package/src/inventory-html.ts +191 -0
  51. package/src/inventory.ts +242 -0
  52. package/src/mcp.ts +21 -5
  53. package/src/opencode-v2.ts +119 -0
  54. package/src/paths.ts +35 -1
  55. package/src/plugin-core.ts +152 -0
  56. package/src/retrieve/display.ts +174 -0
  57. package/src/retrieve/inject.ts +1 -1
  58. package/src/retrieve/search.ts +63 -6
  59. package/src/review.ts +9 -2
  60. package/src/store/db.ts +11 -9
  61. package/src/store/lifecycle.ts +24 -3
  62. package/src/store/markdown.ts +60 -7
  63. package/src/store/migrate.ts +15 -2
  64. package/src/store/sync.ts +6 -3
  65. package/src/store/v2migrate.ts +2 -1
  66. package/src/submit.ts +12 -4
  67. package/src/tools/memory.ts +28 -0
  68. package/src/tools/ops.ts +147 -17
@@ -0,0 +1,119 @@
1
+ import { z } from "zod";
2
+ import {
3
+ PLUGIN_ID,
4
+ bootstrapPlugin,
5
+ captureFromText,
6
+ injectOnce,
7
+ type PluginState,
8
+ } from "./plugin-core.ts";
9
+ import { makeTools } from "./tools/memory.ts";
10
+
11
+ /**
12
+ * OpenCode 2 adapter (D60). The v2 host structurally decodes the plugin's
13
+ * default export as `{ id, setup }` — `Plugin.define` is an identity
14
+ * wrapper, so a plain object with no runtime import of `@opencode/plugin`
15
+ * loads identically. Importing the SDK at runtime would repeat the D58
16
+ * failure (devDependency, unresolvable from a global install, silent
17
+ * skip); the context is duck-typed here from the 2.0.x surface instead.
18
+ *
19
+ * v1/v2 hook mapping used below:
20
+ * tool map → ctx.tool.transform (JSON Schema in,
21
+ * { content } out)
22
+ * chat.message (keyword capture) → ctx.session.hook("prompt") — fires
23
+ * once per submitted prompt
24
+ * experimental.chat.system.transform → ctx.session.hook("context") — runs
25
+ * before each model request; the
26
+ * system array is mutable
27
+ *
28
+ * Tool registration must opt out of code mode (`options.codemode: false`,
29
+ * verified on 2.0.1): by default the host exposes plugin tools only
30
+ * through an `execute` meta-tool that runs model-written JS, and a model
31
+ * that calls the tool by name instead gets "Unknown tool" — flaky,
32
+ * model-dependent behavior. With the opt-out the five tools are plain
33
+ * function calls, same as on v1 and MCP.
34
+ */
35
+
36
+ interface V2ToolInfo {
37
+ name: string;
38
+ description: string;
39
+ input: unknown;
40
+ options?: { codemode?: boolean };
41
+ execute: (input: Record<string, unknown>) => Promise<{ content: string }>;
42
+ }
43
+
44
+ interface V2Registration {
45
+ dispose?: () => Promise<void>;
46
+ }
47
+
48
+ export interface V2PluginContext {
49
+ app?: { version?: string };
50
+ location?: { directory?: string; project?: { directory?: string } };
51
+ tool: {
52
+ transform: (cb: (editor: { add: (tool: V2ToolInfo) => void }) => void) => Promise<unknown>;
53
+ };
54
+ session: {
55
+ hook: (
56
+ name: "prompt" | "context",
57
+ cb: (event: never) => void | Promise<void>,
58
+ ) => Promise<V2Registration>;
59
+ };
60
+ }
61
+
62
+ interface V2PromptEvent {
63
+ sessionID: string;
64
+ prompt?: { text?: string };
65
+ }
66
+
67
+ interface V2ContextEvent {
68
+ sessionID: string;
69
+ system: unknown[];
70
+ }
71
+
72
+ /** Register the five memory tools on a v2 host. */
73
+ async function registerTools(ctx: V2PluginContext, state: PluginState): Promise<void> {
74
+ const tools = makeTools(() => state.scope, state.cfg);
75
+ await ctx.tool.transform((editor) => {
76
+ for (const [name, t] of Object.entries(tools)) {
77
+ editor.add({
78
+ name,
79
+ description: t.description,
80
+ input: z.toJSONSchema(z.object(t.args)),
81
+ options: { codemode: false },
82
+ execute: async (input) => {
83
+ const r = await t.execute(input as never, undefined as never);
84
+ const content =
85
+ typeof r === "string" ? r : ((r as { output?: string }).output ?? JSON.stringify(r));
86
+ return { content };
87
+ },
88
+ });
89
+ }
90
+ });
91
+ }
92
+
93
+ export const v2Plugin = {
94
+ id: PLUGIN_ID,
95
+ async setup(ctx: V2PluginContext) {
96
+ const root = ctx.location?.directory ?? ctx.location?.project?.directory ?? process.cwd();
97
+ const state = bootstrapPlugin(root);
98
+ const registrations: V2Registration[] = [];
99
+
100
+ await registerTools(ctx, state);
101
+
102
+ registrations.push(
103
+ await ctx.session.hook("prompt", (ev: V2PromptEvent) => {
104
+ captureFromText(ev.prompt?.text ?? "", state);
105
+ }),
106
+ );
107
+ registrations.push(
108
+ await ctx.session.hook("context", (ev: V2ContextEvent) => {
109
+ injectOnce(ev.sessionID ?? "", state, (block) => {
110
+ ev.system.push({ type: "text", text: block });
111
+ });
112
+ }),
113
+ );
114
+
115
+ return () => {
116
+ for (const r of registrations) void r.dispose?.();
117
+ };
118
+ },
119
+ };
package/src/paths.ts CHANGED
@@ -3,8 +3,23 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import { execFileSync } from "node:child_process";
5
5
 
6
+ /**
7
+ * D62: the data-root override env var. `OPEN_MEMEX_HOME` is the current
8
+ * name; `MY_O_MEMORY_HOME` (pre-rename package name) is still honored as a
9
+ * fallback so existing setups and scripts keep working. New name wins
10
+ * when both are set.
11
+ */
12
+ export function homeOverride(): { dir: string; via: "OPEN_MEMEX_HOME" | "MY_O_MEMORY_HOME" } | null {
13
+ if (process.env.OPEN_MEMEX_HOME)
14
+ return { dir: path.resolve(process.env.OPEN_MEMEX_HOME), via: "OPEN_MEMEX_HOME" };
15
+ if (process.env.MY_O_MEMORY_HOME)
16
+ return { dir: path.resolve(process.env.MY_O_MEMORY_HOME), via: "MY_O_MEMORY_HOME" };
17
+ return null;
18
+ }
19
+
6
20
  function dataRoot(): string {
7
- if (process.env.MY_O_MEMORY_HOME) return path.resolve(process.env.MY_O_MEMORY_HOME);
21
+ const override = homeOverride();
22
+ if (override) return override.dir;
8
23
  if (process.platform === "win32") {
9
24
  const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
10
25
  return path.join(appData, "open-memex");
@@ -81,6 +96,25 @@ export function inRepoMemoriesDir(root: string, memoryDir: string): string {
81
96
  }
82
97
 
83
98
  /** Same as `inRepoMemoriesDir` but never creates the directory. */
99
+ /**
100
+ * True when a memory file lives inside the user's git working tree (the
101
+ * in-repo `.ai/open-memex/` copy) rather than in the local data root.
102
+ *
103
+ * D70: both destructive/corrective surfaces need this — the hard delete
104
+ * already said "commit the deletion so the team sees it" (D64), and the
105
+ * soft hide has to say the same, because a retraction that never gets
106
+ * committed leaves the memory standing on every teammate's clone.
107
+ */
108
+ export function isInRepoMemoryFile(filePath: string): boolean {
109
+ if (!filePath) return false;
110
+ const normalized = filePath.replace(/\\/g, "/");
111
+ return normalized.includes("/.ai/open-memex/") && !normalized.startsWith(root());
112
+ }
113
+
114
+ function root(): string {
115
+ return paths().root.replace(/\\/g, "/");
116
+ }
117
+
84
118
  export function inRepoMemoriesDirPath(root: string, memoryDir: string): string {
85
119
  return path.join(root, memoryDir);
86
120
  }
@@ -0,0 +1,152 @@
1
+ import { loadConfig, type MyOMemoryConfig } from "./config.ts";
2
+ import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
3
+ import { db } from "./store/db.ts";
4
+ import { syncScope, upsertFromFile } from "./store/sync.ts";
5
+ import { scopeHasFiles } from "./store/migrate.ts";
6
+ import {
7
+ writeMemoryFile,
8
+ readMemoryFile,
9
+ ulid,
10
+ msToRfc3339,
11
+ type Frontmatter,
12
+ } from "./store/markdown.ts";
13
+ import { buildContextBlock } from "./retrieve/inject.ts";
14
+ import { detectKeywords } from "./capture/keywords.ts";
15
+ import { findDuplicates } from "./store/lifecycle.ts";
16
+ import { redact } from "./redact.ts";
17
+
18
+ /**
19
+ * Host-agnostic plugin core (D60). Both opencode host generations adapt to
20
+ * this module: the v1 plugin (src/index.ts `server`) and the v2 plugin
21
+ * (src/opencode-v2.ts `setup`). Host-specific glue — tool registration and
22
+ * hook shapes — stays in the adapters; everything memory-shaped lives here.
23
+ */
24
+ export const PLUGIN_ID = "open-memex";
25
+
26
+ export interface PluginState {
27
+ cfg: MyOMemoryConfig;
28
+ scope: Scope;
29
+ /** Sessions that already received the one-time context injection. */
30
+ injectedSessions: Set<string>;
31
+ }
32
+
33
+ /**
34
+ * Resolve the project scope for a host working directory and bring the
35
+ * index up to date. Never throws: a broken store must not keep the host
36
+ * from starting — failures land in the host's log instead.
37
+ */
38
+ export function bootstrapPlugin(root: string): PluginState {
39
+ const cfg = loadConfig();
40
+ const scope = resolveProjectScope(root);
41
+ try {
42
+ db();
43
+ syncScope(scope.key);
44
+ syncScope(PERSONAL_SCOPE.key);
45
+ if (cfg.logLevel === "debug") {
46
+ console.log(`[open-memex] loaded. scope=${scope.key}`);
47
+ }
48
+ // If a git remote was added *after* memories were first captured, the
49
+ // scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
50
+ // disk and tell the user how to migrate. We do NOT auto-migrate: two
51
+ // different repos at the same cwd would collide.
52
+ const cwdScope = resolveCwdScope(root);
53
+ if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
54
+ console.warn(
55
+ `[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
56
+ `Current project scope is "${scope.key}". ` +
57
+ `To move them: npm run cli -- migrate --from ${cwdScope.key}`,
58
+ );
59
+ }
60
+ } catch (err) {
61
+ console.error("[open-memex] init failed:", err);
62
+ }
63
+ return { cfg, scope, injectedSessions: new Set<string>() };
64
+ }
65
+
66
+ /**
67
+ * Keyword capture (§ capture): scan one user message for "remember …"
68
+ * triggers and save each hit. Shared by the v1 `chat.message` hook and
69
+ * the v2 session `prompt` hook — both hand us the message text.
70
+ */
71
+ export function captureFromText(text: string, state: PluginState): void {
72
+ const { cfg, scope } = state;
73
+ if (!cfg.keywordCaptureEnabled || !text) return;
74
+ const hits = detectKeywords(text, cfg);
75
+ for (const h of hits) {
76
+ const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
77
+ // Secrets are masked (first 4 chars kept) and the capture proceeds;
78
+ // skip only when nothing usable remains.
79
+ if (content.length === 0) continue;
80
+ if (hadSecret && cfg.logLevel === "debug") {
81
+ console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
82
+ }
83
+ // Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
84
+ const target = h.personal ? PERSONAL_SCOPE : scope;
85
+ // Dedup (§3.4): skip exact duplicates captured before.
86
+ if (findDuplicates(target.key, content).exact) continue;
87
+ const now = Date.now();
88
+ const rfc = msToRfc3339(now);
89
+ const fm: Frontmatter = {
90
+ id: ulid(),
91
+ schema_version: 2,
92
+ scope_key: target.key,
93
+ scope: target.kind === "project" ? "project" : "personal",
94
+ visibility: target.kind === "project" ? "internal" : "private",
95
+ project_name: target.projectName,
96
+ type: "fact",
97
+ role: "knowledge",
98
+ importance: "normal",
99
+ status: "active",
100
+ tags: ["keyword"],
101
+ source: "keyword",
102
+ created_at: rfc,
103
+ updated_at: rfc,
104
+ supersedes: null,
105
+ superseded_by: null,
106
+ review_state: "draft",
107
+ proposed_by: null,
108
+ approved_by: null,
109
+ derived_from: null,
110
+ review_note: null,
111
+ review_history: [],
112
+ };
113
+ try {
114
+ const { filePath } = writeMemoryFile(fm, content);
115
+ const mf = readMemoryFile(filePath);
116
+ if (mf) upsertFromFile(mf);
117
+ // Capture feedback: always visible (not debug-only) — the user said
118
+ // "记住…", they should see that it landed. The opencode plugin API
119
+ // offers no toast channel, so the plugin log is the feedback surface.
120
+ const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
121
+ console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
122
+ } catch (err) {
123
+ console.error("[open-memex] keyword capture failed:", err);
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * First-turn context injection: exactly once per session. The host adapter
130
+ * supplies `push` (v1: output.system.push; v2: the session context hook's
131
+ * system array). Returns true when a block was pushed.
132
+ */
133
+ export function injectOnce(
134
+ sessionID: string,
135
+ state: PluginState,
136
+ push: (block: string) => void,
137
+ ): boolean {
138
+ const { cfg, scope } = state;
139
+ if (!cfg.injectOnFirstTurn) return false;
140
+ if (state.injectedSessions.has(sessionID)) return false;
141
+ state.injectedSessions.add(sessionID);
142
+ try {
143
+ const block = buildContextBlock(scope, cfg);
144
+ if (block) {
145
+ push(block);
146
+ return true;
147
+ }
148
+ } catch (err) {
149
+ console.error("[open-memex] context injection failed:", err);
150
+ }
151
+ return false;
152
+ }
@@ -0,0 +1,174 @@
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
+
17
+ /** Human age for a saved memory: "just now", "3 days ago", else a date. */
18
+ export function relativeTime(ts: number, now = Date.now()): string {
19
+ if (!ts || Number.isNaN(ts)) return "";
20
+ const diff = now - ts;
21
+ if (diff < 0) return "just now";
22
+ const minute = 60_000;
23
+ const hour = 60 * minute;
24
+ const day = 24 * hour;
25
+ if (diff < minute) return "just now";
26
+ if (diff < hour) {
27
+ const n = Math.floor(diff / minute);
28
+ return `${n} minute${n === 1 ? "" : "s"} ago`;
29
+ }
30
+ if (diff < day) {
31
+ const n = Math.floor(diff / hour);
32
+ return `${n} hour${n === 1 ? "" : "s"} ago`;
33
+ }
34
+ if (diff < 30 * day) {
35
+ const n = Math.floor(diff / day);
36
+ return `${n} day${n === 1 ? "" : "s"} ago`;
37
+ }
38
+ const d = new Date(ts);
39
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
40
+ const dd = String(d.getDate()).padStart(2, "0");
41
+ return `on ${d.getFullYear()}-${mm}-${dd}`;
42
+ }
43
+
44
+ /** Compact age token for the structured list format: `3d`, `12h`, `now`. */
45
+ export function ageToken(ts: number | undefined, now = Date.now()): string {
46
+ if (!ts || Number.isNaN(ts)) return "?";
47
+ const diff = now - ts;
48
+ if (diff < 60_000) return "now";
49
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
50
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`;
51
+ if (diff < 60 * 86_400_000) return `${Math.floor(diff / 86_400_000)}d`;
52
+ const d = new Date(ts);
53
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
54
+ const dd = String(d.getDate()).padStart(2, "0");
55
+ return `${d.getFullYear()}-${mm}-${dd}`;
56
+ }
57
+
58
+ /**
59
+ * Provenance in the user's words (D68). Used by prose surfaces (HTML
60
+ * report); the structured list keeps the raw `source=` enum instead.
61
+ * The distinction that matters to the user is *whose words these were* —
62
+ * said outright, asked to be saved, auto-captured, or inferred by the
63
+ * agent and possibly wrong.
64
+ */
65
+ export function sourceLabel(source: string | undefined): string {
66
+ switch (source) {
67
+ case "user":
68
+ return "you said this";
69
+ case "cli":
70
+ return "you saved this from the command line";
71
+ case "tool":
72
+ return "you asked me to save this";
73
+ case "keyword":
74
+ return "auto-captured from your own wording";
75
+ case "inference":
76
+ return "I inferred this — not in your words, check me";
77
+ case "import":
78
+ return "imported from another machine";
79
+ default:
80
+ return source ? `saved (${source})` : "saved";
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Review state in plain words for project scopes (D68). Personal
86
+ * memories are always `draft` by design; callers must not show this
87
+ * for them — "not yet shared" would be alarming noise there.
88
+ */
89
+ export function reviewStateLabel(reviewState: string | undefined): string {
90
+ switch (reviewState) {
91
+ case "draft":
92
+ return "still in your outbox — not in the repo yet";
93
+ case "proposed":
94
+ return "shared with the project, waiting for review";
95
+ case "approved":
96
+ return "approved, not yet published";
97
+ case "published":
98
+ return "published to the repo";
99
+ case "rejected":
100
+ return "reviewed and rejected";
101
+ default:
102
+ return reviewState ?? "draft";
103
+ }
104
+ }
105
+
106
+ /** Escape user content for HTML. Memory text is arbitrary user input. */
107
+ export function escapeHtml(s: string): string {
108
+ return s
109
+ .replace(/&/g, "&amp;")
110
+ .replace(/</g, "&lt;")
111
+ .replace(/>/g, "&gt;")
112
+ .replace(/"/g, "&quot;")
113
+ .replace(/'/g, "&#39;");
114
+ }
115
+
116
+ export interface InventoryHit {
117
+ id: string;
118
+ type: string;
119
+ snippet: string;
120
+ source?: string;
121
+ created_at?: number;
122
+ updated_at?: number;
123
+ status: string;
124
+ review_state?: string;
125
+ scope_key: string;
126
+ }
127
+
128
+ /**
129
+ * One structured inventory line (D68):
130
+ * 3. [fact] id=01K6… created=3d source=user [draft] — body text
131
+ * Number and id always appear together: the number is for the user's
132
+ * mouth ("delete #3"), the id is the machine contract. `created=` is
133
+ * when the memory was learned; `updated=` appears only when it moved
134
+ * materially later (supersede/promote touch updated_at).
135
+ *
136
+ * D70: `index` must be a running counter across every section of one
137
+ * listing — a per-section counter produces two "#1"s in a single
138
+ * `scope: both` output, which makes "delete #3" a guess.
139
+ */
140
+ export function formatInventoryLine(
141
+ index: number,
142
+ hit: InventoryHit,
143
+ personalKey: string,
144
+ now = Date.now(),
145
+ ): string {
146
+ const parts = [
147
+ `${index + 1}.`,
148
+ `[${hit.type}]`,
149
+ `id=${hit.id}`,
150
+ `created=${ageToken(hit.created_at ?? hit.updated_at, now)}`,
151
+ `source=${hit.source || "?"}`,
152
+ ];
153
+ const created = hit.created_at ?? hit.updated_at ?? 0;
154
+ if (hit.updated_at && hit.updated_at - created >= 86_400_000) {
155
+ parts.push(`updated=${ageToken(hit.updated_at, now)}`);
156
+ }
157
+ if (hit.scope_key !== personalKey) {
158
+ const state = hit.review_state ?? "draft";
159
+ if (state !== "published") parts.push(`[${state}]`);
160
+ }
161
+ if (hit.status !== "active") parts.push(`[${hit.status}]`);
162
+ const body = hit.snippet.replace(/\s+/g, " ").trim();
163
+ return `${parts.join(" ")} — ${body}`;
164
+ }
165
+
166
+ /**
167
+ * Truncation disclosure (D68): a cut list always says so.
168
+ * D70: the wording names the surface that can show everything — the CLI —
169
+ * so a hidden entry is always reachable.
170
+ */
171
+ export function truncationNote(shown: number, total: number, cli = "open-memex"): string | null {
172
+ if (total <= shown) return null;
173
+ return `… and ${total - shown} more not shown (raise limit up to 100, run \`${cli} list --limit 100\`, or use memory_search to find something specific).`;
174
+ }
@@ -28,7 +28,7 @@ export function buildContextBlock(scope: Scope, cfg: MyOMemoryConfig): string |
28
28
 
29
29
  lines.push(
30
30
  "",
31
- "Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. Do not mention this block to the user unless asked.",
31
+ "Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. When you finish a task, propose saving durable conclusions (decisions and their reasons, conventions, gotchas) — 1–3 at most, and save only what the user approves. Do not mention this block to the user unless asked.",
32
32
  );
33
33
 
34
34
  return lines.join("\n");
@@ -7,11 +7,16 @@ export interface SearchHit {
7
7
  project_name: string;
8
8
  type: string;
9
9
  tags: string[];
10
+ /** D61: capture-time aliases stored on this memory (search hits only). */
11
+ aliases: string[];
10
12
  snippet: string;
11
13
  score: number;
12
14
  updated_at: number;
13
15
  status: string;
14
16
  review_state: string;
17
+ /** D68: provenance + creation time for the plain-language inventory. */
18
+ source: string;
19
+ created_at: number;
15
20
  }
16
21
 
17
22
  interface RawRow {
@@ -20,26 +25,40 @@ interface RawRow {
20
25
  project_name: string;
21
26
  type: string;
22
27
  tags: string;
28
+ aliases?: string;
23
29
  updated_at: number;
24
30
  snippet: string;
25
31
  score: number;
26
32
  status: string;
27
33
  superseded_by: string | null;
28
34
  review_state: string;
35
+ source?: string;
36
+ created_at?: number;
29
37
  }
30
38
 
31
39
  function toHit(r: RawRow): SearchHit {
40
+ const aliases = r.aliases ? r.aliases.split(",").map((a) => a.trim()).filter(Boolean) : [];
41
+ let snippet = r.snippet ?? "";
42
+ // D61: the snippet highlights matches in the body column only. When the
43
+ // match came through an alias (body shows no highlight), say so — the
44
+ // hit otherwise looks unrelated to the query.
45
+ if (aliases.length > 0 && !/\[[^\]]+\]/.test(snippet)) {
46
+ snippet += ` (aka: ${aliases.join(", ")})`;
47
+ }
32
48
  return {
33
49
  id: r.id,
34
50
  scope_key: r.scope_key,
35
51
  project_name: r.project_name,
36
52
  type: r.type,
37
53
  tags: r.tags ? r.tags.split(",").filter(Boolean) : [],
38
- snippet: r.snippet ?? "",
54
+ aliases,
55
+ snippet,
39
56
  score: r.score,
40
57
  updated_at: r.updated_at,
41
58
  status: r.status,
42
59
  review_state: r.review_state ?? "draft",
60
+ source: r.source ?? "",
61
+ created_at: r.created_at ?? r.updated_at ?? 0,
43
62
  };
44
63
  }
45
64
 
@@ -87,7 +106,7 @@ function resolveVisible(rows: RawRow[], limit: number, stats?: SearchStats): Sea
87
106
  const r = db()
88
107
  .prepare(
89
108
  `SELECT id, scope_key, project_name, type, tags, updated_at, status,
90
- superseded_by, review_state, substr(content, 1, 240) AS snippet
109
+ superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
91
110
  FROM memories WHERE id = ?`,
92
111
  )
93
112
  .get(id) as
@@ -161,8 +180,8 @@ export function search(
161
180
  const typeFilter = opts.type ? ` AND m.type = ?` : "";
162
181
 
163
182
  const sql = `
164
- SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
165
- m.status, m.superseded_by, m.review_state,
183
+ SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.updated_at,
184
+ m.status, m.superseded_by, m.review_state, m.source, m.created_at,
166
185
  snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
167
186
  bm25(memories_fts) AS score
168
187
  FROM memories_fts
@@ -188,6 +207,8 @@ export function search(
188
207
  status: string;
189
208
  superseded_by: string | null;
190
209
  review_state: string;
210
+ source: string;
211
+ created_at: number;
191
212
  snippet: string;
192
213
  score: number;
193
214
  }>;
@@ -200,14 +221,14 @@ export function search(
200
221
 
201
222
  export function list(
202
223
  scopeKey: string,
203
- opts: { type?: string; limit?: number } = {},
224
+ opts: { type?: string; limit?: number; include?: "active" | "all" } = {},
204
225
  ): SearchHit[] {
205
226
  const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
206
227
  const fetchLimit = Math.min(limit * 3 + 10, 150);
207
228
  const typeFilter = opts.type ? ` AND type = ?` : "";
208
229
  const sql = `
209
230
  SELECT id, scope_key, project_name, type, tags, updated_at, status,
210
- superseded_by, review_state, substr(content, 1, 240) AS snippet
231
+ superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
211
232
  FROM memories
212
233
  WHERE scope_key = ?${typeFilter}
213
234
  ORDER BY updated_at DESC
@@ -229,9 +250,45 @@ export function list(
229
250
  status: string;
230
251
  superseded_by: string | null;
231
252
  review_state: string;
253
+ source: string;
254
+ created_at: number;
232
255
  snippet: string;
233
256
  }>;
234
257
 
235
258
  const raw: RawRow[] = rows.map((r) => ({ ...r, score: 1 }));
259
+ if (opts.include === "all") {
260
+ // D68: the audit view — every row as stored (superseded versions,
261
+ // retracted, archived), no chain collapsing, capped at limit.
262
+ return raw.slice(0, limit).map(toHit);
263
+ }
236
264
  return resolveVisible(raw, limit);
237
265
  }
266
+
267
+ /**
268
+ * True totals for the inventory's truncation disclosure (D68). Under
269
+ * `active` the visible set is the newest of each chain, which in a
270
+ * consistent store is exactly the rows whose status is active/deprecated.
271
+ *
272
+ * D70: one deliberate exception — a `superseded` row whose chain target is
273
+ * missing (a dangling pointer, which the read path self-heals with a
274
+ * warning) is returned as a visible hit by resolveVisible but is not
275
+ * counted here, so the total can read one low in that state. Not worth
276
+ * widening the query for a self-healing case; the disclosure stays honest
277
+ * in every state a user can reach deliberately.
278
+ */
279
+ export function countList(
280
+ scopeKey: string,
281
+ opts: { type?: string; include?: "active" | "all" } = {},
282
+ ): number {
283
+ const typeFilter = opts.type ? ` AND type = ?` : "";
284
+ const statusFilter =
285
+ opts.include === "all" ? "" : ` AND status IN ('active', 'deprecated')`;
286
+ const params: unknown[] = [scopeKey];
287
+ if (opts.type) params.push(opts.type);
288
+ const row = db()
289
+ .prepare(
290
+ `SELECT COUNT(*) AS n FROM memories WHERE scope_key = ?${statusFilter}${typeFilter}`,
291
+ )
292
+ .get(...(params as any[])) as { n: number };
293
+ return row.n;
294
+ }
package/src/review.ts CHANGED
@@ -27,6 +27,7 @@ import { resolveProjectScope } from "./scope.ts";
27
27
  import {
28
28
  parse as parseMemory,
29
29
  serialize,
30
+ atomicWriteTextSync,
30
31
  ulid,
31
32
  writeMemoryFile,
32
33
  type Frontmatter,
@@ -107,6 +108,12 @@ function copyPersonalToProject(
107
108
  id: ulid(),
108
109
  scope: "project",
109
110
  scope_key: project.key,
111
+ // D64: the copy is headed for the team. Source visibility (usually
112
+ // "private") must not ride along: export excludes private by default,
113
+ // so a private copy would silently vanish from every bundle the team
114
+ // builds. Proposing IS the act of sharing — the copy is internal;
115
+ // the personal original keeps its own visibility, untouched.
116
+ visibility: "internal",
110
117
  project_name: project.projectName,
111
118
  created_at: now,
112
119
  updated_at: now,
@@ -240,7 +247,7 @@ export function promoteMemory(
240
247
  },
241
248
  ],
242
249
  };
243
- fs.writeFileSync(mf.filePath, serialize(fm, mf.body), "utf8");
250
+ atomicWriteTextSync(mf.filePath, serialize(fm, mf.body));
244
251
  upsertFromFile({ fm, body: mf.body, filePath: mf.filePath, mtimeMs: Date.now() });
245
252
  return { id, from, to, history: fm.review_history };
246
253
  }
@@ -494,7 +501,7 @@ export function resolveConflict(target: string): ResolveOutcome {
494
501
  if (conflicts.length > 0) return { ok: false, filePath, conflicts };
495
502
 
496
503
  const fm = merged as unknown as Frontmatter;
497
- fs.writeFileSync(filePath, serialize(fm, mergedBody), "utf8");
504
+ atomicWriteTextSync(filePath, serialize(fm, mergedBody));
498
505
  upsertFromFile({ fm, body: mergedBody, filePath, mtimeMs: Date.now() });
499
506
  return { ok: true, filePath, autoMerged: [...new Set(autoMerged)] };
500
507
  }