open-memex 0.7.0-alpha.9 → 0.7.0

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/inventory.ts CHANGED
@@ -31,6 +31,7 @@ export interface InventoryRow {
31
31
  updated_at: number;
32
32
  content: string;
33
33
  file_path: string;
34
+ superseded_by?: string | null;
34
35
  }
35
36
 
36
37
  export interface InventoryData {
@@ -41,6 +42,12 @@ export interface InventoryData {
41
42
  outbox: InventoryRow[];
42
43
  /** History that exists but is not "remembered": counted, not listed. */
43
44
  hidden: { superseded: number; retracted: number; archived: number };
45
+ /**
46
+ * The hidden rows themselves — audit surfaces only. D70: loaded when
47
+ * the caller asks for `hidden: "entries"` (the HTML report); text/json
48
+ * report the counts and must not pay to load every retired body.
49
+ */
50
+ hiddenEntries: InventoryRow[];
44
51
  total: number;
45
52
  }
46
53
 
@@ -48,10 +55,17 @@ const BASE_COLS = `id, scope_key, project_name, type, status, review_state,
48
55
  source, created_at, updated_at, content, file_path`;
49
56
 
50
57
  /**
51
- * Load the inventory for the given scopes. `projectScopeKey` separates
52
- * the outbox drafts (they belong to that scope's draft rows).
58
+ * Load the inventory for the given scopes. `projectScopeKey` separates the
59
+ * outbox drafts (they belong to that scope's draft rows).
60
+ *
61
+ * D70: `hidden: "entries"` fetches the hidden rows themselves (HTML audit
62
+ * view); the default keeps the cheap COUNT query, because pulling every
63
+ * superseded body for a text report nobody reads is pure waste.
53
64
  */
54
- export function loadInventory(scopeKeys: string[]): InventoryData {
65
+ export function loadInventory(
66
+ scopeKeys: string[],
67
+ opts: { hidden?: "counts" | "entries" } = {},
68
+ ): InventoryData {
55
69
  const inList = scopeKeys.map(() => "?").join(",");
56
70
  const current = db()
57
71
  .prepare(
@@ -60,19 +74,36 @@ export function loadInventory(scopeKeys: string[]): InventoryData {
60
74
  ORDER BY scope_key, updated_at DESC`,
61
75
  )
62
76
  .all(...scopeKeys) as unknown as InventoryRow[];
63
- const hiddenRows = db()
64
- .prepare(
65
- `SELECT status, COUNT(*) AS n FROM memories
66
- WHERE scope_key IN (${inList}) AND status IN ('superseded', 'retracted', 'archived')
67
- GROUP BY status`,
68
- )
69
- .all(...scopeKeys) as unknown as Array<{ status: string; n: number }>;
70
-
77
+ const hiddenEntries: InventoryRow[] =
78
+ opts.hidden === "entries"
79
+ ? (db()
80
+ .prepare(
81
+ `SELECT ${BASE_COLS}, superseded_by FROM memories
82
+ WHERE scope_key IN (${inList}) AND status IN ('superseded', 'retracted', 'archived')
83
+ ORDER BY updated_at DESC`,
84
+ )
85
+ .all(...scopeKeys) as unknown as InventoryRow[])
86
+ : [];
71
87
  const hidden = { superseded: 0, retracted: 0, archived: 0 };
72
- for (const r of hiddenRows) {
73
- if (r.status === "superseded") hidden.superseded = r.n;
74
- if (r.status === "retracted") hidden.retracted = r.n;
75
- if (r.status === "archived") hidden.archived = r.n;
88
+ if (hiddenEntries.length > 0) {
89
+ for (const r of hiddenEntries) {
90
+ if (r.status === "superseded") hidden.superseded++;
91
+ if (r.status === "retracted") hidden.retracted++;
92
+ if (r.status === "archived") hidden.archived++;
93
+ }
94
+ } else {
95
+ const counted = db()
96
+ .prepare(
97
+ `SELECT status, COUNT(*) AS n FROM memories
98
+ WHERE scope_key IN (${inList}) AND status IN ('superseded', 'retracted', 'archived')
99
+ GROUP BY status`,
100
+ )
101
+ .all(...scopeKeys) as unknown as Array<{ status: string; n: number }>;
102
+ for (const r of counted) {
103
+ if (r.status === "superseded") hidden.superseded = r.n;
104
+ if (r.status === "retracted") hidden.retracted = r.n;
105
+ if (r.status === "archived") hidden.archived = r.n;
106
+ }
76
107
  }
77
108
 
78
109
  const outbox = current.filter(
@@ -88,26 +119,28 @@ export function loadInventory(scopeKeys: string[]): InventoryData {
88
119
  byScope.set(r.scope_key, arr);
89
120
  }
90
121
  // Personal first, then project scopes by key for a stable order.
122
+ // D70: every requested scope appears, including one with nothing in it —
123
+ // "this project has no memories" is part of the answer, and dropping the
124
+ // section made the report look like the project was never looked at.
91
125
  const orderedKeys = [
92
126
  ...scopeKeys.filter((k) => k === PERSONAL_SCOPE.key),
93
- ...[...byScope.keys()].filter((k) => k !== PERSONAL_SCOPE.key).sort(),
127
+ ...scopeKeys.filter((k) => k !== PERSONAL_SCOPE.key).sort(),
94
128
  ];
95
- const scopes = orderedKeys
96
- .filter((k) => byScope.has(k))
97
- .map((k) => {
98
- const rows = byScope.get(k)!;
99
- return {
100
- scopeKey: k,
101
- projectName: rows[0]?.project_name || k,
102
- rows,
103
- };
104
- });
129
+ const scopes = orderedKeys.map((k) => {
130
+ const rows = byScope.get(k) ?? [];
131
+ return {
132
+ scopeKey: k,
133
+ projectName: rows[0]?.project_name || k,
134
+ rows,
135
+ };
136
+ });
105
137
 
106
138
  return {
107
139
  generatedAt: new Date().toISOString(),
108
140
  scopes,
109
141
  outbox,
110
142
  hidden,
143
+ hiddenEntries,
111
144
  total: current.length,
112
145
  };
113
146
  }
@@ -118,11 +151,12 @@ function hiddenLine(data: InventoryData): string {
118
151
  return `Not shown: ${superseded} superseded version(s), ${retracted} retracted, ${archived} archived — inspect with \`open-memex list --include all\`.`;
119
152
  }
120
153
 
121
- function entriesText(rows: InventoryRow[]): string {
154
+ function entriesText(rows: InventoryRow[], startAt = 0): string {
155
+ if (rows.length === 0) return "(nothing remembered in this scope yet)";
122
156
  return rows
123
157
  .map((r, i) =>
124
158
  formatInventoryLine(
125
- i,
159
+ startAt + i,
126
160
  { ...r, snippet: r.content },
127
161
  PERSONAL_SCOPE.key,
128
162
  ),
@@ -137,20 +171,25 @@ export function renderInventoryText(data: InventoryData): string {
137
171
  out.push(
138
172
  `${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).`,
139
173
  );
174
+ // D70: one running counter across every section, so a number quoted back
175
+ // ("delete #3") names exactly one entry in this report.
176
+ let seq = 0;
140
177
  for (const s of data.scopes) {
141
178
  const title =
142
179
  s.scopeKey === PERSONAL_SCOPE.key
143
180
  ? "About you — personal (never leaves this machine)"
144
181
  : `Project: ${s.projectName}`;
145
- out.push("", `## ${title} (${s.rows.length})`, entriesText(s.rows));
182
+ out.push("", `## ${title} (${s.rows.length})`, entriesText(s.rows, seq));
183
+ seq += s.rows.length;
146
184
  }
147
185
  if (data.outbox.length > 0) {
148
186
  out.push(
149
187
  "",
150
188
  `## Not in the repo yet — outbox drafts (${data.outbox.length})`,
151
189
  "These exist only on this machine. They reach the repo through review (submit → promote), never automatically.",
152
- entriesText(data.outbox),
190
+ entriesText(data.outbox, seq),
153
191
  );
192
+ seq += data.outbox.length;
154
193
  }
155
194
  const hidden = hiddenLine(data);
156
195
  if (hidden) out.push("", hidden);
@@ -161,7 +200,14 @@ export function renderInventoryText(data: InventoryData): string {
161
200
  return out.join("\n") + "\n";
162
201
  }
163
202
 
164
- /** JSON rendering: same data, for agents. Mirrors the export field names. */
203
+ /**
204
+ * JSON rendering: same data, for agents. Mirrors the export field names.
205
+ *
206
+ * D70: format `/2` drops the absolute `file` path. This artifact is meant
207
+ * to be handed to an agent — i.e. pasted into a cloud conversation — and it
208
+ * carried the user's home directory and name with it. The id locates the
209
+ * file (`open-memex where` prints the data root).
210
+ */
165
211
  export function renderInventoryJson(data: InventoryData): string {
166
212
  const entry = (r: InventoryRow) => ({
167
213
  id: r.id,
@@ -174,12 +220,11 @@ export function renderInventoryJson(data: InventoryData): string {
174
220
  created_at: new Date(r.created_at).toISOString(),
175
221
  updated_at: new Date(r.updated_at).toISOString(),
176
222
  content: r.content,
177
- file: r.file_path,
178
223
  });
179
224
  return (
180
225
  JSON.stringify(
181
226
  {
182
- format: "open-memex-inventory/1",
227
+ format: "open-memex-inventory/2",
183
228
  generated_at: data.generatedAt,
184
229
  total: data.total,
185
230
  scopes: data.scopes.map((s) => ({
@@ -3,6 +3,7 @@ import {
3
3
  PLUGIN_ID,
4
4
  bootstrapPlugin,
5
5
  captureFromText,
6
+ captureHandoff,
6
7
  injectOnce,
7
8
  type PluginState,
8
9
  } from "./plugin-core.ts";
@@ -101,14 +102,18 @@ export const v2Plugin = {
101
102
 
102
103
  registrations.push(
103
104
  await ctx.session.hook("prompt", (ev: V2PromptEvent) => {
104
- captureFromText(ev.prompt?.text ?? "", state);
105
+ captureFromText(ev.prompt?.text ?? "", state, ev.sessionID ?? "");
105
106
  }),
106
107
  );
107
108
  registrations.push(
108
109
  await ctx.session.hook("context", (ev: V2ContextEvent) => {
109
- injectOnce(ev.sessionID ?? "", state, (block) => {
110
+ const sessionID = ev.sessionID ?? "";
111
+ injectOnce(sessionID, state, (block) => {
110
112
  ev.system.push({ type: "text", text: block });
111
113
  });
114
+ // D73: tell the agent what the keyword hook already stored this turn.
115
+ const handoff = captureHandoff(sessionID, state);
116
+ if (handoff) ev.system.push({ type: "text", text: handoff });
112
117
  }),
113
118
  );
114
119
 
package/src/paths.ts CHANGED
@@ -96,6 +96,25 @@ export function inRepoMemoriesDir(root: string, memoryDir: string): string {
96
96
  }
97
97
 
98
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
+
99
118
  export function inRepoMemoriesDirPath(root: string, memoryDir: string): string {
100
119
  return path.join(root, memoryDir);
101
120
  }
@@ -12,6 +12,12 @@ import {
12
12
  } from "./store/markdown.ts";
13
13
  import { buildContextBlock } from "./retrieve/inject.ts";
14
14
  import { detectKeywords } from "./capture/keywords.ts";
15
+ import {
16
+ TURN_ECHO_WINDOW_MS,
17
+ formatHandoffBlock,
18
+ freshCaptures,
19
+ type CaptureRecord,
20
+ } from "./capture/handoff.ts";
15
21
  import { findDuplicates } from "./store/lifecycle.ts";
16
22
  import { redact } from "./redact.ts";
17
23
 
@@ -28,6 +34,12 @@ export interface PluginState {
28
34
  scope: Scope;
29
35
  /** Sessions that already received the one-time context injection. */
30
36
  injectedSessions: Set<string>;
37
+ /**
38
+ * D73: what the keyword hook stored this session, so the agent can be told
39
+ * before it re-saves the same statement. In memory only, like
40
+ * `injectedSessions` — never persisted, pruned by the turn window.
41
+ */
42
+ captures: Map<string, CaptureRecord[]>;
31
43
  }
32
44
 
33
45
  /**
@@ -60,15 +72,21 @@ export function bootstrapPlugin(root: string): PluginState {
60
72
  } catch (err) {
61
73
  console.error("[open-memex] init failed:", err);
62
74
  }
63
- return { cfg, scope, injectedSessions: new Set<string>() };
75
+ return { cfg, scope, injectedSessions: new Set<string>(), captures: new Map() };
64
76
  }
65
77
 
66
78
  /**
67
79
  * Keyword capture (§ capture): scan one user message for "remember …"
68
80
  * 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.
81
+ * the v2 session `prompt` hook — both hand us the message text and the
82
+ * session id. The session id is what makes the D73 hand-off possible: without
83
+ * it the agent never learns the sentence is already stored.
70
84
  */
71
- export function captureFromText(text: string, state: PluginState): void {
85
+ export function captureFromText(
86
+ text: string,
87
+ state: PluginState,
88
+ sessionID?: string,
89
+ ): void {
72
90
  const { cfg, scope } = state;
73
91
  if (!cfg.keywordCaptureEnabled || !text) return;
74
92
  const hits = detectKeywords(text, cfg);
@@ -119,12 +137,53 @@ export function captureFromText(text: string, state: PluginState): void {
119
137
  // offers no toast channel, so the plugin log is the feedback surface.
120
138
  const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
121
139
  console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
140
+ // D73 hand-off: the agent gets told about this capture in the turn's
141
+ // system context, so it does not save the same statement again.
142
+ recordCapture(state, sessionID, { id: fm.id, scopeKey: target.key, content, at: now });
122
143
  } catch (err) {
123
144
  console.error("[open-memex] keyword capture failed:", err);
124
145
  }
125
146
  }
126
147
  }
127
148
 
149
+ /** Note one hook capture against its session (D73). No-op without a session. */
150
+ function recordCapture(
151
+ state: PluginState,
152
+ sessionID: string | undefined,
153
+ record: CaptureRecord,
154
+ ): void {
155
+ if (!sessionID) return;
156
+ const list = state.captures.get(sessionID) ?? [];
157
+ list.push(record);
158
+ state.captures.set(sessionID, list);
159
+ }
160
+
161
+ /**
162
+ * D73 hand-off note (a): what the keyword hook already stored for this
163
+ * session, delivered once per capture into the turn's system context. The
164
+ * agent cannot avoid re-saving a statement it cannot see is stored, so the
165
+ * store tells it. Returns "" when there is nothing new to hand off; stale
166
+ * records are pruned (in-memory only, like `injectedSessions`).
167
+ *
168
+ * Both host adapters push this from their system/context hook — the one that
169
+ * runs before the model request, i.e. after `chat.message` / `prompt`.
170
+ */
171
+ export function captureHandoff(sessionID: string, state: PluginState): string {
172
+ if (!sessionID) return "";
173
+ const list = state.captures.get(sessionID);
174
+ if (!list || list.length === 0) return "";
175
+ const now = Date.now();
176
+ const fresh = freshCaptures(list, now);
177
+ if (fresh.length !== list.length) {
178
+ if (fresh.length === 0) state.captures.delete(sessionID);
179
+ else state.captures.set(sessionID, fresh);
180
+ }
181
+ const pending = fresh.filter((r) => !r.delivered);
182
+ if (pending.length === 0) return "";
183
+ for (const r of pending) r.delivered = true;
184
+ return formatHandoffBlock(pending);
185
+ }
186
+
128
187
  /**
129
188
  * First-turn context injection: exactly once per session. The host adapter
130
189
  * supplies `push` (v1: output.system.push; v2: the session context hook's
@@ -132,6 +132,10 @@ export interface InventoryHit {
132
132
  * mouth ("delete #3"), the id is the machine contract. `created=` is
133
133
  * when the memory was learned; `updated=` appears only when it moved
134
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.
135
139
  */
136
140
  export function formatInventoryLine(
137
141
  index: number,
@@ -159,8 +163,12 @@ export function formatInventoryLine(
159
163
  return `${parts.join(" ")} — ${body}`;
160
164
  }
161
165
 
162
- /** Truncation disclosure (D68): a cut list always says so. */
163
- export function truncationNote(shown: number, total: number): string | null {
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 {
164
172
  if (total <= shown) return null;
165
- return `… and ${total - shown} more not shown (raise limit up to 100, or use memory_search to find something specific).`;
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).`;
166
174
  }
@@ -266,9 +266,15 @@ export function list(
266
266
 
267
267
  /**
268
268
  * True totals for the inventory's truncation disclosure (D68). Under
269
- * `active` the visible set is the newest of each chain — which is
270
- * exactly the rows whose status is active/deprecated, so a plain count
271
- * matches what resolveVisible would return unbounded.
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.
272
278
  */
273
279
  export function countList(
274
280
  scopeKey: string,
package/src/scope.ts CHANGED
@@ -68,6 +68,24 @@ export function resolveCwdScope(worktree: string): Scope {
68
68
  return scopeFromSeed(seed, projectName);
69
69
  }
70
70
 
71
+ /** Pick the directory that seeds scope resolution from a host's candidate
72
+ * roots. OpenCode v1 hands the plugin both `worktree` (the repo root — but
73
+ * the filesystem root, e.g. `C:\` or `/`, when the opened folder is not a
74
+ * git repo) and `directory` (the folder actually opened). Seeding on a
75
+ * filesystem root collapses every non-git folder on a drive into one shared
76
+ * `project__workspace__…` scope (D71), so prefer `worktree` only when it is
77
+ * a real directory below the filesystem root. */
78
+ export function pickScopeRoot(
79
+ worktree?: string | null,
80
+ directory?: string | null,
81
+ ): string {
82
+ const isFsRoot = (p: string) => path.parse(p).root === p;
83
+ if (worktree && !isFsRoot(worktree)) return worktree;
84
+ if (directory) return directory;
85
+ if (worktree) return worktree;
86
+ return process.cwd();
87
+ }
88
+
71
89
  export function resolveProjectScope(worktree: string): Scope {
72
90
  const target = worktree && worktree.length > 0 ? worktree : ".";
73
91
  const remote = tryGitRemote(target);
@@ -9,6 +9,7 @@ import {
9
9
  } from "../paths.ts";
10
10
  import { loadConfig } from "../config.ts";
11
11
  import { cjkIndexText } from "../retrieve/cjk.ts";
12
+ import { isTurnEcho } from "../capture/handoff.ts";
12
13
  import {
13
14
  parse,
14
15
  serialize,
@@ -67,6 +68,11 @@ export interface DupResult {
67
68
 
68
69
  export const NEAR_DUP_THRESHOLD = 0.8;
69
70
 
71
+ /** `source` values the keyword hook (or an agent marking a user statement) writes.
72
+ * Only these are turn-echo candidates — a `tool` memory is the agent's own
73
+ * inference and keeps the 0.8 near-dup notice instead of a refusal. */
74
+ const ECHO_SOURCES = "('keyword','user')";
75
+
70
76
  interface IndexRow {
71
77
  id: string;
72
78
  content_hash: string;
@@ -103,6 +109,52 @@ export function findDuplicates(
103
109
  return { exact, near: near.slice(0, 3) };
104
110
  }
105
111
 
112
+ export interface TurnEchoCandidate extends DuplicateCandidate {
113
+ source: string;
114
+ }
115
+
116
+ /**
117
+ * D73: memories written from the user's own words moments ago — the keyword
118
+ * hook's captures, plus agent saves marked `source: user`. `memory_add` uses
119
+ * this to refuse a re-save of a statement the store already holds verbatim
120
+ * (see TURN_ECHO_THRESHOLD in src/capture/handoff.ts for why the bar is high).
121
+ * `created_at` is epoch ms in the index, so the window is a numeric compare.
122
+ */
123
+ export function findTurnEcho(
124
+ scopeKey: string,
125
+ body: string,
126
+ windowMs: number,
127
+ ): TurnEchoCandidate[] {
128
+ const cutoff = Date.now() - windowMs;
129
+ let rows: Array<{ id: string; content: string; source: string; created_at: number }>;
130
+ try {
131
+ rows = db()
132
+ .prepare(
133
+ `SELECT id, content, source, created_at FROM memories
134
+ WHERE scope_key = ? AND status = 'active'
135
+ AND source IN ${ECHO_SOURCES} AND created_at >= ?`,
136
+ )
137
+ .all(scopeKey, cutoff) as typeof rows;
138
+ } catch {
139
+ return [];
140
+ }
141
+ const out: TurnEchoCandidate[] = [];
142
+ for (const r of rows) {
143
+ if (typeof r.created_at !== "number" || r.created_at < cutoff) continue;
144
+ const score = similarity(body, r.content);
145
+ if (isTurnEcho(score)) {
146
+ out.push({
147
+ id: r.id,
148
+ score,
149
+ source: r.source,
150
+ snippet: r.content.replace(/\s+/g, " ").trim().slice(0, 120),
151
+ });
152
+ }
153
+ }
154
+ out.sort((x, y) => y.score - x.score);
155
+ return out.slice(0, 3);
156
+ }
157
+
106
158
  /** Locate a memory file by id: index file_path first, then dir-scan fallback. */
107
159
  export function findMemoryFile(id: string): MemoryFile | null {
108
160
  const { memories } = paths();
package/src/tools/ops.ts CHANGED
@@ -7,7 +7,6 @@
7
7
  * result; each host adapts that to its own tool-result shape.
8
8
  */
9
9
  import fs from "node:fs";
10
- import path from "node:path";
11
10
  import { z } from "zod";
12
11
  import type { Scope } from "../scope.ts";
13
12
  import type { MyOMemoryConfig } from "../config.ts";
@@ -24,12 +23,13 @@ import {
24
23
  type Frontmatter,
25
24
  } from "../store/markdown.ts";
26
25
  import { upsertFromFile, deleteFromIndex } from "../store/sync.ts";
27
- import { findDuplicates, supersede, setStatus } from "../store/lifecycle.ts";
26
+ import { findDuplicates, findTurnEcho, supersede, setStatus } from "../store/lifecycle.ts";
27
+ import { TURN_ECHO_WINDOW_MS } from "../capture/handoff.ts";
28
28
  import { db } from "../store/db.ts";
29
29
  import { redact } from "../redact.ts";
30
30
  import { getSyncStatus, formatSyncStatus, submitMemories, outboxDraftCount } from "../submit.ts";
31
31
  import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.ts";
32
- import { paths } from "../paths.ts";
32
+ import { isInRepoMemoryFile } from "../paths.ts";
33
33
  import {
34
34
  proposeMemories,
35
35
  promoteMemory,
@@ -66,7 +66,7 @@ export async function withOutboxNote(
66
66
  /** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
67
67
  export const TOOL_DESCRIPTIONS = {
68
68
  memory_add:
69
- "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Worth saving: decisions and their reasons, preferences, conventions, gotchas, approaches tried and abandoned. Not worth saving: one-off task details or anything re-derivable from the code. Do not wait to be asked. For facts you infer yourself rather than the user stating, propose them first and save only on approval. Keep each memory to one self-contained statement; attach aliases when the parameter is available. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
69
+ "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Worth saving: decisions and their reasons, preferences, conventions, gotchas, approaches tried and abandoned. Not worth saving: one-off task details or anything re-derivable from the code. Do not wait to be asked. For facts you infer yourself rather than the user stating, propose them first and save only on approval. Keep each memory to one self-contained statement; attach aliases when the parameter is available. If a note tells you the user's own wording was already stored verbatim this turn, that statement is already saved: do not retry it in a rewording — save only what that text does not contain, or use memory_supersede on the given id. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
70
70
  memory_search:
71
71
  "Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or personal scope. Call before asking the user about past decisions, conventions, or preferences they may have told you before. Search well: break the question into its concepts and try 2–3 phrasings per concept — synonyms, the user's other language, shorter keyword forms — and check the other scope too, before concluding nothing is stored.",
72
72
  memory_list:
@@ -295,6 +295,27 @@ export async function addMemory(
295
295
  output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
296
296
  };
297
297
  }
298
+ // D73: one user statement, one memory. The keyword hook stores the user's
299
+ // sentence verbatim with no agent in the loop; a paraphrase of that same
300
+ // sentence is refused instead of written beside it. The stored id comes back
301
+ // so the caller can supersede it or save only the genuinely new part.
302
+ const echo = findTurnEcho(s.key, redacted, TURN_ECHO_WINDOW_MS);
303
+ if (echo.length > 0) {
304
+ const top = echo[0];
305
+ const others = echo
306
+ .slice(1)
307
+ .map((e) => `id=${e.id} (${e.source}, overlap ${e.score.toFixed(2)})`)
308
+ .join(", ");
309
+ return {
310
+ title: "memory: already captured verbatim",
311
+ output:
312
+ `Not saved — the user's own words were already stored moments ago (source: ${top.source}, overlap ${top.score.toFixed(2)}): ` +
313
+ `id=${top.id} — ${top.snippet}` +
314
+ (others ? `\nAlso matching: ${others}` : "") +
315
+ `\nDo not retry with a rewording: that only adds a second copy of one statement. ` +
316
+ `Save a separate memory only for information that stored text does not contain, or call memory_supersede on that id if this replaces it.`,
317
+ };
318
+ }
298
319
  const fm = buildFrontmatter(s, {
299
320
  type: args.type ?? "fact",
300
321
  tags: args.tags ?? [],
@@ -357,10 +378,13 @@ export async function listMemories(
357
378
  // every section reports its true total — a truncated list says so
358
379
  // instead of quietly looking complete (the D66 sync-status lesson).
359
380
  const include = args.include ?? "active";
381
+ // D70: numbering runs across every section of THIS listing, so "delete
382
+ // #3" names exactly one memory. A per-section counter emitted two "#1"s.
383
+ let seq = 0;
360
384
  const render = (scopeKey: string) => {
361
385
  const hits = list(scopeKey, { type: args.type, limit: args.limit, include });
362
386
  const total = countList(scopeKey, { type: args.type, include });
363
- const lines = hits.map((h, i) => formatInventoryLine(i, h, PERSONAL_SCOPE.key));
387
+ const lines = hits.map((h) => formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
364
388
  const note = truncationNote(hits.length, total);
365
389
  if (note) lines.push(note);
366
390
  return lines;
@@ -455,7 +479,14 @@ export async function forgetMemory(args: MemoryForgetArgs): Promise<ToolResult>
455
479
  upsertFromFile(mf);
456
480
  return {
457
481
  title: "memory: hidden",
458
- output: `Hidden ${args.id} (retracted). It no longer appears in lists or search, but the file is kept. This is one-way — to bring the fact back, save it again as a new memory.`,
482
+ output:
483
+ `Hidden ${args.id} (retracted). It no longer appears in lists or search, but the file is kept. This is one-way — to bring the fact back, save it again as a new memory.` +
484
+ // D70: a retraction of an in-repo memory is a local working-tree
485
+ // edit like the delete — without a commit (and a push) the memory
486
+ // stands on every teammate's clone, so "hidden" would be a lie.
487
+ (isInRepoMemoryFile(row.file_path)
488
+ ? ` Note: this memory lives in the repo (.ai/open-memex/), so the retraction is only local until you commit and push it — until then the team still sees it.`
489
+ : ""),
459
490
  };
460
491
  } catch (e) {
461
492
  return { title: "memory: hide failed", output: (e as Error).message };
@@ -482,9 +513,7 @@ export async function forgetMemory(args: MemoryForgetArgs): Promise<ToolResult>
482
513
  // D64: forgetting an in-repo memory deletes a file inside the user's
483
514
  // git working tree. Say so — the deletion should ride a commit, or the
484
515
  // file silently diverges from what teammates have.
485
- const inRepo =
486
- row.file_path.includes(`${path.sep}.ai${path.sep}`) &&
487
- !row.file_path.startsWith(paths().root);
516
+ const inRepo = isInRepoMemoryFile(row.file_path);
488
517
  return {
489
518
  title: "memory: forgotten",
490
519
  output: inRepo