open-memex 0.7.0-alpha.8 → 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/AGENTS.md +8 -3
- package/CHANGELOG.md +40 -0
- package/README.md +17 -3
- package/README.zh-CN.md +12 -3
- package/dist/.build-stamp +1 -1
- package/dist/capture/handoff.js +59 -0
- package/dist/cli.js +188 -19
- package/dist/distill-agents.js +4 -1
- package/dist/index.js +14 -6
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +176 -0
- package/dist/mcp.js +9 -0
- package/dist/opencode-v2.js +8 -3
- package/dist/paths.js +18 -0
- package/dist/plugin-core.js +48 -3
- package/dist/retrieve/display.js +161 -0
- package/dist/retrieve/search.js +33 -3
- package/dist/scope.js +17 -0
- package/dist/store/lifecycle.js +42 -0
- package/dist/tools/ops.js +92 -22
- package/docs/TEST-PLAN.md +1 -1
- package/docs/V2-DESIGN.md +117 -2
- package/docs/memory-visibility-design.md +130 -0
- package/package.json +2 -2
- package/scripts/build-stamp.mjs +62 -62
- package/scripts/smoke-mcp.ts +47 -1
- package/scripts/smoke-pure.ts +85 -1
- package/scripts/test-full.ts +192 -5
- package/skills/open-memex/SKILL.md +29 -0
- package/src/capture/handoff.ts +74 -0
- package/src/cli.ts +202 -19
- package/src/distill-agents.ts +7 -0
- package/src/index.ts +13 -5
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +242 -0
- package/src/mcp.ts +9 -0
- package/src/opencode-v2.ts +7 -2
- package/src/paths.ts +19 -0
- package/src/plugin-core.ts +62 -3
- package/src/retrieve/display.ts +174 -0
- package/src/retrieve/search.ts +49 -4
- package/src/scope.ts +18 -0
- package/src/store/lifecycle.ts +52 -0
- package/src/tools/ops.ts +99 -27
|
@@ -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, "&")
|
|
110
|
+
.replace(/</g, "<")
|
|
111
|
+
.replace(/>/g, ">")
|
|
112
|
+
.replace(/"/g, """)
|
|
113
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|
package/src/retrieve/search.ts
CHANGED
|
@@ -14,6 +14,9 @@ export interface SearchHit {
|
|
|
14
14
|
updated_at: number;
|
|
15
15
|
status: string;
|
|
16
16
|
review_state: string;
|
|
17
|
+
/** D68: provenance + creation time for the plain-language inventory. */
|
|
18
|
+
source: string;
|
|
19
|
+
created_at: number;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
interface RawRow {
|
|
@@ -29,6 +32,8 @@ interface RawRow {
|
|
|
29
32
|
status: string;
|
|
30
33
|
superseded_by: string | null;
|
|
31
34
|
review_state: string;
|
|
35
|
+
source?: string;
|
|
36
|
+
created_at?: number;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
function toHit(r: RawRow): SearchHit {
|
|
@@ -52,6 +57,8 @@ function toHit(r: RawRow): SearchHit {
|
|
|
52
57
|
updated_at: r.updated_at,
|
|
53
58
|
status: r.status,
|
|
54
59
|
review_state: r.review_state ?? "draft",
|
|
60
|
+
source: r.source ?? "",
|
|
61
|
+
created_at: r.created_at ?? r.updated_at ?? 0,
|
|
55
62
|
};
|
|
56
63
|
}
|
|
57
64
|
|
|
@@ -99,7 +106,7 @@ function resolveVisible(rows: RawRow[], limit: number, stats?: SearchStats): Sea
|
|
|
99
106
|
const r = db()
|
|
100
107
|
.prepare(
|
|
101
108
|
`SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
102
|
-
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
109
|
+
superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
|
|
103
110
|
FROM memories WHERE id = ?`,
|
|
104
111
|
)
|
|
105
112
|
.get(id) as
|
|
@@ -174,7 +181,7 @@ export function search(
|
|
|
174
181
|
|
|
175
182
|
const sql = `
|
|
176
183
|
SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.updated_at,
|
|
177
|
-
m.status, m.superseded_by, m.review_state,
|
|
184
|
+
m.status, m.superseded_by, m.review_state, m.source, m.created_at,
|
|
178
185
|
snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
|
|
179
186
|
bm25(memories_fts) AS score
|
|
180
187
|
FROM memories_fts
|
|
@@ -200,6 +207,8 @@ export function search(
|
|
|
200
207
|
status: string;
|
|
201
208
|
superseded_by: string | null;
|
|
202
209
|
review_state: string;
|
|
210
|
+
source: string;
|
|
211
|
+
created_at: number;
|
|
203
212
|
snippet: string;
|
|
204
213
|
score: number;
|
|
205
214
|
}>;
|
|
@@ -212,14 +221,14 @@ export function search(
|
|
|
212
221
|
|
|
213
222
|
export function list(
|
|
214
223
|
scopeKey: string,
|
|
215
|
-
opts: { type?: string; limit?: number } = {},
|
|
224
|
+
opts: { type?: string; limit?: number; include?: "active" | "all" } = {},
|
|
216
225
|
): SearchHit[] {
|
|
217
226
|
const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
|
|
218
227
|
const fetchLimit = Math.min(limit * 3 + 10, 150);
|
|
219
228
|
const typeFilter = opts.type ? ` AND type = ?` : "";
|
|
220
229
|
const sql = `
|
|
221
230
|
SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
222
|
-
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
231
|
+
superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
|
|
223
232
|
FROM memories
|
|
224
233
|
WHERE scope_key = ?${typeFilter}
|
|
225
234
|
ORDER BY updated_at DESC
|
|
@@ -241,9 +250,45 @@ export function list(
|
|
|
241
250
|
status: string;
|
|
242
251
|
superseded_by: string | null;
|
|
243
252
|
review_state: string;
|
|
253
|
+
source: string;
|
|
254
|
+
created_at: number;
|
|
244
255
|
snippet: string;
|
|
245
256
|
}>;
|
|
246
257
|
|
|
247
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
|
+
}
|
|
248
264
|
return resolveVisible(raw, limit);
|
|
249
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/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);
|
package/src/store/lifecycle.ts
CHANGED
|
@@ -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,12 +7,12 @@
|
|
|
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";
|
|
14
13
|
import { PERSONAL_SCOPE } from "../scope.ts";
|
|
15
|
-
import { search, list, hitStateLabel } from "../retrieve/search.ts";
|
|
14
|
+
import { search, list, countList, hitStateLabel } from "../retrieve/search.ts";
|
|
15
|
+
import { formatInventoryLine, truncationNote } from "../retrieve/display.ts";
|
|
16
16
|
import {
|
|
17
17
|
writeMemoryFile,
|
|
18
18
|
readMemoryFile,
|
|
@@ -23,12 +23,13 @@ import {
|
|
|
23
23
|
type Frontmatter,
|
|
24
24
|
} from "../store/markdown.ts";
|
|
25
25
|
import { upsertFromFile, deleteFromIndex } from "../store/sync.ts";
|
|
26
|
-
import { findDuplicates, supersede } from "../store/lifecycle.ts";
|
|
26
|
+
import { findDuplicates, findTurnEcho, supersede, setStatus } from "../store/lifecycle.ts";
|
|
27
|
+
import { TURN_ECHO_WINDOW_MS } from "../capture/handoff.ts";
|
|
27
28
|
import { db } from "../store/db.ts";
|
|
28
29
|
import { redact } from "../redact.ts";
|
|
29
30
|
import { getSyncStatus, formatSyncStatus, submitMemories, outboxDraftCount } from "../submit.ts";
|
|
30
31
|
import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.ts";
|
|
31
|
-
import {
|
|
32
|
+
import { isInRepoMemoryFile } from "../paths.ts";
|
|
32
33
|
import {
|
|
33
34
|
proposeMemories,
|
|
34
35
|
promoteMemory,
|
|
@@ -65,14 +66,15 @@ export async function withOutboxNote(
|
|
|
65
66
|
/** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
|
|
66
67
|
export const TOOL_DESCRIPTIONS = {
|
|
67
68
|
memory_add:
|
|
68
|
-
"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.",
|
|
69
70
|
memory_search:
|
|
70
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.",
|
|
71
72
|
memory_list:
|
|
72
|
-
"List memories in a scope, newest first.
|
|
73
|
+
"List memories in a scope, newest first, as a numbered inventory. Each line keeps the raw fields — [type] id=… created=… source=… — say them back to the user in plain words (source=user → 'you told me this'; inference → 'I inferred this, check me'; keyword → 'caught from your own wording'), never read the raw id aloud unless they ask. When the user asks 'what do you remember about me?', use scope=both and present the inventory conversationally. The user may point at an entry by its number ('delete #3'): numbers are only valid for the listing you just produced — re-run memory_list, read the candidate back in full, and get a confirmation before calling memory_forget (deletion is permanent). include=all is the audit view (superseded versions, retracted, archived also shown).",
|
|
73
74
|
memory_supersede:
|
|
74
75
|
"Replace an existing memory with a newer version. The old memory is kept as history (status: superseded) and retrieval returns the new one. Use when a saved fact becomes outdated and should be replaced rather than duplicated.",
|
|
75
|
-
memory_forget:
|
|
76
|
+
memory_forget:
|
|
77
|
+
"Delete a memory by id. Use when the user asks to forget something. Deletion is permanent: before deleting, restate the memory's content to the user and get a confirmation. With soft=true the memory is hidden instead (retracted: out of lists and search, file kept, one-way).",
|
|
76
78
|
memory_status:
|
|
77
79
|
"Show the project memory sync pipeline: drafts waiting in the outbox (appdata), memories in the repo awaiting review or published, and any repo files not yet committed. Call this at session start, when the server reports drafts waiting for review, or when the user says 'sync memory' (or '同步记忆'); then ask the user which drafts to sync. (MCP server and the `open-memex sync-status` CLI; the opencode native plugin does not expose this tool.)",
|
|
78
80
|
memory_submit:
|
|
@@ -143,6 +145,12 @@ export const memoryListArgs = {
|
|
|
143
145
|
),
|
|
144
146
|
type: z.string().optional(),
|
|
145
147
|
limit: z.number().int().min(1).max(100).optional(),
|
|
148
|
+
include: z
|
|
149
|
+
.enum(["active", "all"])
|
|
150
|
+
.optional()
|
|
151
|
+
.describe(
|
|
152
|
+
"D68: `active` (default) shows what is currently remembered — newest version of each fact, retracted/archived hidden. `all` is the audit view: every stored row including superseded versions and hidden ones. The default CHANGED in D68: superseded versions no longer appear unless include=all.",
|
|
153
|
+
),
|
|
146
154
|
};
|
|
147
155
|
export type MemoryListArgs = z.infer<z.ZodObject<typeof memoryListArgs>>;
|
|
148
156
|
|
|
@@ -160,6 +168,12 @@ export type MemorySupersedeArgs = z.infer<z.ZodObject<typeof memorySupersedeArgs
|
|
|
160
168
|
|
|
161
169
|
export const memoryForgetArgs = {
|
|
162
170
|
id: z.string().min(1),
|
|
171
|
+
soft: z
|
|
172
|
+
.boolean()
|
|
173
|
+
.optional()
|
|
174
|
+
.describe(
|
|
175
|
+
"D68: hide instead of delete. The memory is retracted — excluded from lists and search — but its file stays. Retraction is one-way (D64): it does not come back; to restore the fact, save it again as a new memory. Offer `soft` when the user is unsure about deleting.",
|
|
176
|
+
),
|
|
163
177
|
};
|
|
164
178
|
export type MemoryForgetArgs = z.infer<z.ZodObject<typeof memoryForgetArgs>>;
|
|
165
179
|
|
|
@@ -281,6 +295,27 @@ export async function addMemory(
|
|
|
281
295
|
output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
|
|
282
296
|
};
|
|
283
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
|
+
}
|
|
284
319
|
const fm = buildFrontmatter(s, {
|
|
285
320
|
type: args.type ?? "fact",
|
|
286
321
|
tags: args.tags ?? [],
|
|
@@ -338,24 +373,37 @@ export async function listMemories(
|
|
|
338
373
|
getScope: () => Scope,
|
|
339
374
|
args: MemoryListArgs,
|
|
340
375
|
): Promise<ToolResult> {
|
|
376
|
+
// D68: the list is the user's inventory of what is remembered. Lines
|
|
377
|
+
// are structured (number + id + created= + source= + state tags) and
|
|
378
|
+
// every section reports its true total — a truncated list says so
|
|
379
|
+
// instead of quietly looking complete (the D66 sync-status lesson).
|
|
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;
|
|
384
|
+
const render = (scopeKey: string) => {
|
|
385
|
+
const hits = list(scopeKey, { type: args.type, limit: args.limit, include });
|
|
386
|
+
const total = countList(scopeKey, { type: args.type, include });
|
|
387
|
+
const lines = hits.map((h) => formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
|
|
388
|
+
const note = truncationNote(hits.length, total);
|
|
389
|
+
if (note) lines.push(note);
|
|
390
|
+
return lines;
|
|
391
|
+
};
|
|
341
392
|
if (args.scope === "both") {
|
|
342
393
|
// D64: "what do you remember about me" spans both stores; search
|
|
343
394
|
// already defaults to both, list now matches. Each scope lists its
|
|
344
395
|
// own newest (the limit applies per scope).
|
|
345
396
|
const project = getScope();
|
|
346
397
|
const sections: string[] = [];
|
|
347
|
-
for (const s of [
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
(h) => `- [${h.type}]${hitStateLabel(h)} id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`,
|
|
355
|
-
);
|
|
356
|
-
sections.push(`## ${s.kind} (${s.key})\n${lines.join("\n")}`);
|
|
398
|
+
for (const s of [PERSONAL_SCOPE, project]) {
|
|
399
|
+
const lines = render(s.key);
|
|
400
|
+
const heading =
|
|
401
|
+
s.key === PERSONAL_SCOPE.key
|
|
402
|
+
? "## About you (personal — stays on this machine, applies everywhere)"
|
|
403
|
+
: `## This project (${s.projectName || s.key})`;
|
|
404
|
+
sections.push(lines.length === 0 ? `${heading}\n(none yet)` : `${heading}\n${lines.join("\n")}`);
|
|
357
405
|
}
|
|
358
|
-
return { title: "memory: list (both scopes)", output: sections.join("\n") };
|
|
406
|
+
return { title: "memory: list (both scopes)", output: sections.join("\n\n") };
|
|
359
407
|
}
|
|
360
408
|
const s = resolveScope(
|
|
361
409
|
getScope,
|
|
@@ -363,14 +411,18 @@ export async function listMemories(
|
|
|
363
411
|
? args.scope
|
|
364
412
|
: undefined,
|
|
365
413
|
);
|
|
366
|
-
const
|
|
367
|
-
if (
|
|
414
|
+
const lines = render(s.key);
|
|
415
|
+
if (lines.length === 0) {
|
|
368
416
|
return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
|
|
369
417
|
}
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
418
|
+
const heading =
|
|
419
|
+
s.key === PERSONAL_SCOPE.key
|
|
420
|
+
? "## About you (personal — stays on this machine, applies everywhere)"
|
|
421
|
+
: `## This project (${s.projectName || s.key})`;
|
|
422
|
+
return {
|
|
423
|
+
title: `memory: ${countList(s.key, { type: args.type, include })} in ${s.key}`,
|
|
424
|
+
output: `${heading}\n${lines.join("\n")}`,
|
|
425
|
+
};
|
|
374
426
|
}
|
|
375
427
|
|
|
376
428
|
export async function supersedeMemory(
|
|
@@ -418,6 +470,28 @@ export async function forgetMemory(args: MemoryForgetArgs): Promise<ToolResult>
|
|
|
418
470
|
if (!row) {
|
|
419
471
|
return { title: "memory: not found", output: `No memory with id ${args.id}.` };
|
|
420
472
|
}
|
|
473
|
+
if (args.soft) {
|
|
474
|
+
// D68: hide instead of delete. The status flip is the whole action —
|
|
475
|
+
// retrieval and the inventory exclude retracted, the file stays put.
|
|
476
|
+
// One-way (D64): no un-hide; restoring the fact means saving it again.
|
|
477
|
+
try {
|
|
478
|
+
const mf = setStatus(args.id, "retracted");
|
|
479
|
+
upsertFromFile(mf);
|
|
480
|
+
return {
|
|
481
|
+
title: "memory: hidden",
|
|
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
|
+
: ""),
|
|
490
|
+
};
|
|
491
|
+
} catch (e) {
|
|
492
|
+
return { title: "memory: hide failed", output: (e as Error).message };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
421
495
|
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
422
496
|
// Only ENOENT counts as "deleted": on any other unlink failure the file
|
|
423
497
|
// is still the source of truth and would be re-indexed on next sync, so
|
|
@@ -439,9 +513,7 @@ export async function forgetMemory(args: MemoryForgetArgs): Promise<ToolResult>
|
|
|
439
513
|
// D64: forgetting an in-repo memory deletes a file inside the user's
|
|
440
514
|
// git working tree. Say so — the deletion should ride a commit, or the
|
|
441
515
|
// file silently diverges from what teammates have.
|
|
442
|
-
const inRepo =
|
|
443
|
-
row.file_path.includes(`${path.sep}.ai${path.sep}`) &&
|
|
444
|
-
!row.file_path.startsWith(paths().root);
|
|
516
|
+
const inRepo = isInRepoMemoryFile(row.file_path);
|
|
445
517
|
return {
|
|
446
518
|
title: "memory: forgotten",
|
|
447
519
|
output: inRepo
|