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
package/dist/retrieve/search.js
CHANGED
|
@@ -21,6 +21,8 @@ function toHit(r) {
|
|
|
21
21
|
updated_at: r.updated_at,
|
|
22
22
|
status: r.status,
|
|
23
23
|
review_state: r.review_state ?? "draft",
|
|
24
|
+
source: r.source ?? "",
|
|
25
|
+
created_at: r.created_at ?? r.updated_at ?? 0,
|
|
24
26
|
};
|
|
25
27
|
}
|
|
26
28
|
/**
|
|
@@ -61,7 +63,7 @@ function resolveVisible(rows, limit, stats) {
|
|
|
61
63
|
return cached;
|
|
62
64
|
const r = db()
|
|
63
65
|
.prepare(`SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
64
|
-
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
66
|
+
superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
|
|
65
67
|
FROM memories WHERE id = ?`)
|
|
66
68
|
.get(id);
|
|
67
69
|
if (!r)
|
|
@@ -134,7 +136,7 @@ export function search(query, opts = {}) {
|
|
|
134
136
|
const typeFilter = opts.type ? ` AND m.type = ?` : "";
|
|
135
137
|
const sql = `
|
|
136
138
|
SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.updated_at,
|
|
137
|
-
m.status, m.superseded_by, m.review_state,
|
|
139
|
+
m.status, m.superseded_by, m.review_state, m.source, m.created_at,
|
|
138
140
|
snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
|
|
139
141
|
bm25(memories_fts) AS score
|
|
140
142
|
FROM memories_fts
|
|
@@ -164,7 +166,7 @@ export function list(scopeKey, opts = {}) {
|
|
|
164
166
|
const typeFilter = opts.type ? ` AND type = ?` : "";
|
|
165
167
|
const sql = `
|
|
166
168
|
SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
167
|
-
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
169
|
+
superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
|
|
168
170
|
FROM memories
|
|
169
171
|
WHERE scope_key = ?${typeFilter}
|
|
170
172
|
ORDER BY updated_at DESC
|
|
@@ -178,5 +180,33 @@ export function list(scopeKey, opts = {}) {
|
|
|
178
180
|
.prepare(sql)
|
|
179
181
|
.all(...params);
|
|
180
182
|
const raw = rows.map((r) => ({ ...r, score: 1 }));
|
|
183
|
+
if (opts.include === "all") {
|
|
184
|
+
// D68: the audit view — every row as stored (superseded versions,
|
|
185
|
+
// retracted, archived), no chain collapsing, capped at limit.
|
|
186
|
+
return raw.slice(0, limit).map(toHit);
|
|
187
|
+
}
|
|
181
188
|
return resolveVisible(raw, limit);
|
|
182
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* True totals for the inventory's truncation disclosure (D68). Under
|
|
192
|
+
* `active` the visible set is the newest of each chain, which in a
|
|
193
|
+
* consistent store is exactly the rows whose status is active/deprecated.
|
|
194
|
+
*
|
|
195
|
+
* D70: one deliberate exception — a `superseded` row whose chain target is
|
|
196
|
+
* missing (a dangling pointer, which the read path self-heals with a
|
|
197
|
+
* warning) is returned as a visible hit by resolveVisible but is not
|
|
198
|
+
* counted here, so the total can read one low in that state. Not worth
|
|
199
|
+
* widening the query for a self-healing case; the disclosure stays honest
|
|
200
|
+
* in every state a user can reach deliberately.
|
|
201
|
+
*/
|
|
202
|
+
export function countList(scopeKey, opts = {}) {
|
|
203
|
+
const typeFilter = opts.type ? ` AND type = ?` : "";
|
|
204
|
+
const statusFilter = opts.include === "all" ? "" : ` AND status IN ('active', 'deprecated')`;
|
|
205
|
+
const params = [scopeKey];
|
|
206
|
+
if (opts.type)
|
|
207
|
+
params.push(opts.type);
|
|
208
|
+
const row = db()
|
|
209
|
+
.prepare(`SELECT COUNT(*) AS n FROM memories WHERE scope_key = ?${statusFilter}${typeFilter}`)
|
|
210
|
+
.get(...params);
|
|
211
|
+
return row.n;
|
|
212
|
+
}
|
package/dist/scope.js
CHANGED
|
@@ -57,6 +57,23 @@ export function resolveCwdScope(worktree) {
|
|
|
57
57
|
projectName = "workspace";
|
|
58
58
|
return scopeFromSeed(seed, projectName);
|
|
59
59
|
}
|
|
60
|
+
/** Pick the directory that seeds scope resolution from a host's candidate
|
|
61
|
+
* roots. OpenCode v1 hands the plugin both `worktree` (the repo root — but
|
|
62
|
+
* the filesystem root, e.g. `C:\` or `/`, when the opened folder is not a
|
|
63
|
+
* git repo) and `directory` (the folder actually opened). Seeding on a
|
|
64
|
+
* filesystem root collapses every non-git folder on a drive into one shared
|
|
65
|
+
* `project__workspace__…` scope (D71), so prefer `worktree` only when it is
|
|
66
|
+
* a real directory below the filesystem root. */
|
|
67
|
+
export function pickScopeRoot(worktree, directory) {
|
|
68
|
+
const isFsRoot = (p) => path.parse(p).root === p;
|
|
69
|
+
if (worktree && !isFsRoot(worktree))
|
|
70
|
+
return worktree;
|
|
71
|
+
if (directory)
|
|
72
|
+
return directory;
|
|
73
|
+
if (worktree)
|
|
74
|
+
return worktree;
|
|
75
|
+
return process.cwd();
|
|
76
|
+
}
|
|
60
77
|
export function resolveProjectScope(worktree) {
|
|
61
78
|
const target = worktree && worktree.length > 0 ? worktree : ".";
|
|
62
79
|
const remote = tryGitRemote(target);
|
package/dist/store/lifecycle.js
CHANGED
|
@@ -5,6 +5,7 @@ import { db } from "./db.js";
|
|
|
5
5
|
import { paths, projectRoot, inRepoMemoriesDirPath, } from "../paths.js";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
7
|
import { cjkIndexText } from "../retrieve/cjk.js";
|
|
8
|
+
import { isTurnEcho } from "../capture/handoff.js";
|
|
8
9
|
import { parse, serialize, atomicWriteTextSync, ulid, msToRfc3339, } from "./markdown.js";
|
|
9
10
|
/**
|
|
10
11
|
* Dedup + lifecycle (V2-DESIGN §3.3, §3.4).
|
|
@@ -40,6 +41,10 @@ export function similarity(a, b) {
|
|
|
40
41
|
return union === 0 ? 0 : inter / union;
|
|
41
42
|
}
|
|
42
43
|
export const NEAR_DUP_THRESHOLD = 0.8;
|
|
44
|
+
/** `source` values the keyword hook (or an agent marking a user statement) writes.
|
|
45
|
+
* Only these are turn-echo candidates — a `tool` memory is the agent's own
|
|
46
|
+
* inference and keeps the 0.8 near-dup notice instead of a refusal. */
|
|
47
|
+
const ECHO_SOURCES = "('keyword','user')";
|
|
43
48
|
/** Find exact + near duplicates of `body` among ACTIVE memories in scope. */
|
|
44
49
|
export function findDuplicates(scopeKey, body, excludeId) {
|
|
45
50
|
const hash = contentHash(body);
|
|
@@ -64,6 +69,43 @@ export function findDuplicates(scopeKey, body, excludeId) {
|
|
|
64
69
|
near.sort((x, y) => y.score - x.score);
|
|
65
70
|
return { exact, near: near.slice(0, 3) };
|
|
66
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* D73: memories written from the user's own words moments ago — the keyword
|
|
74
|
+
* hook's captures, plus agent saves marked `source: user`. `memory_add` uses
|
|
75
|
+
* this to refuse a re-save of a statement the store already holds verbatim
|
|
76
|
+
* (see TURN_ECHO_THRESHOLD in src/capture/handoff.ts for why the bar is high).
|
|
77
|
+
* `created_at` is epoch ms in the index, so the window is a numeric compare.
|
|
78
|
+
*/
|
|
79
|
+
export function findTurnEcho(scopeKey, body, windowMs) {
|
|
80
|
+
const cutoff = Date.now() - windowMs;
|
|
81
|
+
let rows;
|
|
82
|
+
try {
|
|
83
|
+
rows = db()
|
|
84
|
+
.prepare(`SELECT id, content, source, created_at FROM memories
|
|
85
|
+
WHERE scope_key = ? AND status = 'active'
|
|
86
|
+
AND source IN ${ECHO_SOURCES} AND created_at >= ?`)
|
|
87
|
+
.all(scopeKey, cutoff);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const r of rows) {
|
|
94
|
+
if (typeof r.created_at !== "number" || r.created_at < cutoff)
|
|
95
|
+
continue;
|
|
96
|
+
const score = similarity(body, r.content);
|
|
97
|
+
if (isTurnEcho(score)) {
|
|
98
|
+
out.push({
|
|
99
|
+
id: r.id,
|
|
100
|
+
score,
|
|
101
|
+
source: r.source,
|
|
102
|
+
snippet: r.content.replace(/\s+/g, " ").trim().slice(0, 120),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
out.sort((x, y) => y.score - x.score);
|
|
107
|
+
return out.slice(0, 3);
|
|
108
|
+
}
|
|
67
109
|
/** Locate a memory file by id: index file_path first, then dir-scan fallback. */
|
|
68
110
|
export function findMemoryFile(id) {
|
|
69
111
|
const { memories } = paths();
|
package/dist/tools/ops.js
CHANGED
|
@@ -7,18 +7,19 @@
|
|
|
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 { PERSONAL_SCOPE } from "../scope.js";
|
|
13
|
-
import { search, list, hitStateLabel } from "../retrieve/search.js";
|
|
12
|
+
import { search, list, countList, hitStateLabel } from "../retrieve/search.js";
|
|
13
|
+
import { formatInventoryLine, truncationNote } from "../retrieve/display.js";
|
|
14
14
|
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, normalizeAliases, } from "../store/markdown.js";
|
|
15
15
|
import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
|
|
16
|
-
import { findDuplicates, supersede } from "../store/lifecycle.js";
|
|
16
|
+
import { findDuplicates, findTurnEcho, supersede, setStatus } from "../store/lifecycle.js";
|
|
17
|
+
import { TURN_ECHO_WINDOW_MS } from "../capture/handoff.js";
|
|
17
18
|
import { db } from "../store/db.js";
|
|
18
19
|
import { redact } from "../redact.js";
|
|
19
20
|
import { getSyncStatus, formatSyncStatus, submitMemories, outboxDraftCount } from "../submit.js";
|
|
20
21
|
import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.js";
|
|
21
|
-
import {
|
|
22
|
+
import { isInRepoMemoryFile } from "../paths.js";
|
|
22
23
|
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory, } from "../review.js";
|
|
23
24
|
/**
|
|
24
25
|
* D53: push, don't poll. Append the project-outbox pending count to mutating
|
|
@@ -37,11 +38,11 @@ export async function withOutboxNote(scopeKey, p, reviewHint) {
|
|
|
37
38
|
}
|
|
38
39
|
/** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
|
|
39
40
|
export const TOOL_DESCRIPTIONS = {
|
|
40
|
-
memory_add: "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.",
|
|
41
|
+
memory_add: "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.",
|
|
41
42
|
memory_search: "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.",
|
|
42
|
-
memory_list: "List memories in a scope, newest first.
|
|
43
|
+
memory_list: "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).",
|
|
43
44
|
memory_supersede: "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.",
|
|
44
|
-
memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
|
|
45
|
+
memory_forget: "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).",
|
|
45
46
|
memory_status: "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.)",
|
|
46
47
|
memory_submit: "Move outbox drafts into the repo memory dir for review: copies the drafts in as proposed (or keeps a local approval), commits locally on the current branch, and moves the outbox originals out. Never creates a branch on its own — pass branch= only with the user's explicit approval for the full chain. Prints the push and PR commands — those need the user's explicit approval and are never run automatically. (MCP server and the `open-memex submit` CLI; the opencode native plugin does not expose this tool.)",
|
|
47
48
|
memory_propose: "Copy personal memories into the project outbox as review drafts. The personal originals stay put. (MCP server and the `open-memex propose` CLI; the opencode native plugin does not expose this tool.)",
|
|
@@ -91,6 +92,10 @@ export const memoryListArgs = {
|
|
|
91
92
|
.describe("Which scope(s) to list. `project` = tied to this repo. `personal` = global across all your projects. `both` lists each scope's newest. Default: project."),
|
|
92
93
|
type: z.string().optional(),
|
|
93
94
|
limit: z.number().int().min(1).max(100).optional(),
|
|
95
|
+
include: z
|
|
96
|
+
.enum(["active", "all"])
|
|
97
|
+
.optional()
|
|
98
|
+
.describe("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."),
|
|
94
99
|
};
|
|
95
100
|
export const memorySupersedeArgs = {
|
|
96
101
|
id: z.string().min(1).describe("ID of the memory being replaced."),
|
|
@@ -104,6 +109,10 @@ export const memorySupersedeArgs = {
|
|
|
104
109
|
};
|
|
105
110
|
export const memoryForgetArgs = {
|
|
106
111
|
id: z.string().min(1),
|
|
112
|
+
soft: z
|
|
113
|
+
.boolean()
|
|
114
|
+
.optional()
|
|
115
|
+
.describe("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."),
|
|
107
116
|
};
|
|
108
117
|
export const memoryStatusArgs = {};
|
|
109
118
|
export const memorySubmitArgs = {
|
|
@@ -189,6 +198,26 @@ export async function addMemory(getScope, cfg, args) {
|
|
|
189
198
|
output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
|
|
190
199
|
};
|
|
191
200
|
}
|
|
201
|
+
// D73: one user statement, one memory. The keyword hook stores the user's
|
|
202
|
+
// sentence verbatim with no agent in the loop; a paraphrase of that same
|
|
203
|
+
// sentence is refused instead of written beside it. The stored id comes back
|
|
204
|
+
// so the caller can supersede it or save only the genuinely new part.
|
|
205
|
+
const echo = findTurnEcho(s.key, redacted, TURN_ECHO_WINDOW_MS);
|
|
206
|
+
if (echo.length > 0) {
|
|
207
|
+
const top = echo[0];
|
|
208
|
+
const others = echo
|
|
209
|
+
.slice(1)
|
|
210
|
+
.map((e) => `id=${e.id} (${e.source}, overlap ${e.score.toFixed(2)})`)
|
|
211
|
+
.join(", ");
|
|
212
|
+
return {
|
|
213
|
+
title: "memory: already captured verbatim",
|
|
214
|
+
output: `Not saved — the user's own words were already stored moments ago (source: ${top.source}, overlap ${top.score.toFixed(2)}): ` +
|
|
215
|
+
`id=${top.id} — ${top.snippet}` +
|
|
216
|
+
(others ? `\nAlso matching: ${others}` : "") +
|
|
217
|
+
`\nDo not retry with a rewording: that only adds a second copy of one statement. ` +
|
|
218
|
+
`Save a separate memory only for information that stored text does not contain, or call memory_supersede on that id if this replaces it.`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
192
221
|
const fm = buildFrontmatter(s, {
|
|
193
222
|
type: args.type ?? "fact",
|
|
194
223
|
tags: args.tags ?? [],
|
|
@@ -233,32 +262,52 @@ export async function searchMemories(getScope, args) {
|
|
|
233
262
|
return { title: `memory: ${hits.length} result(s)`, output: lines.join("\n") };
|
|
234
263
|
}
|
|
235
264
|
export async function listMemories(getScope, args) {
|
|
265
|
+
// D68: the list is the user's inventory of what is remembered. Lines
|
|
266
|
+
// are structured (number + id + created= + source= + state tags) and
|
|
267
|
+
// every section reports its true total — a truncated list says so
|
|
268
|
+
// instead of quietly looking complete (the D66 sync-status lesson).
|
|
269
|
+
const include = args.include ?? "active";
|
|
270
|
+
// D70: numbering runs across every section of THIS listing, so "delete
|
|
271
|
+
// #3" names exactly one memory. A per-section counter emitted two "#1"s.
|
|
272
|
+
let seq = 0;
|
|
273
|
+
const render = (scopeKey) => {
|
|
274
|
+
const hits = list(scopeKey, { type: args.type, limit: args.limit, include });
|
|
275
|
+
const total = countList(scopeKey, { type: args.type, include });
|
|
276
|
+
const lines = hits.map((h) => formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
|
|
277
|
+
const note = truncationNote(hits.length, total);
|
|
278
|
+
if (note)
|
|
279
|
+
lines.push(note);
|
|
280
|
+
return lines;
|
|
281
|
+
};
|
|
236
282
|
if (args.scope === "both") {
|
|
237
283
|
// D64: "what do you remember about me" spans both stores; search
|
|
238
284
|
// already defaults to both, list now matches. Each scope lists its
|
|
239
285
|
// own newest (the limit applies per scope).
|
|
240
286
|
const project = getScope();
|
|
241
287
|
const sections = [];
|
|
242
|
-
for (const s of [
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
const lines = hits.map((h) => `- [${h.type}]${hitStateLabel(h)} id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
249
|
-
sections.push(`## ${s.kind} (${s.key})\n${lines.join("\n")}`);
|
|
288
|
+
for (const s of [PERSONAL_SCOPE, project]) {
|
|
289
|
+
const lines = render(s.key);
|
|
290
|
+
const heading = s.key === PERSONAL_SCOPE.key
|
|
291
|
+
? "## About you (personal — stays on this machine, applies everywhere)"
|
|
292
|
+
: `## This project (${s.projectName || s.key})`;
|
|
293
|
+
sections.push(lines.length === 0 ? `${heading}\n(none yet)` : `${heading}\n${lines.join("\n")}`);
|
|
250
294
|
}
|
|
251
|
-
return { title: "memory: list (both scopes)", output: sections.join("\n") };
|
|
295
|
+
return { title: "memory: list (both scopes)", output: sections.join("\n\n") };
|
|
252
296
|
}
|
|
253
297
|
const s = resolveScope(getScope, args.scope === "project" || args.scope === "personal" || args.scope === "user"
|
|
254
298
|
? args.scope
|
|
255
299
|
: undefined);
|
|
256
|
-
const
|
|
257
|
-
if (
|
|
300
|
+
const lines = render(s.key);
|
|
301
|
+
if (lines.length === 0) {
|
|
258
302
|
return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
|
|
259
303
|
}
|
|
260
|
-
const
|
|
261
|
-
|
|
304
|
+
const heading = s.key === PERSONAL_SCOPE.key
|
|
305
|
+
? "## About you (personal — stays on this machine, applies everywhere)"
|
|
306
|
+
: `## This project (${s.projectName || s.key})`;
|
|
307
|
+
return {
|
|
308
|
+
title: `memory: ${countList(s.key, { type: args.type, include })} in ${s.key}`,
|
|
309
|
+
output: `${heading}\n${lines.join("\n")}`,
|
|
310
|
+
};
|
|
262
311
|
}
|
|
263
312
|
export async function supersedeMemory(cfg, args) {
|
|
264
313
|
const { content: redacted, hadSecret, matchedPattern } = redact(args.content, cfg.redactPatterns);
|
|
@@ -296,6 +345,28 @@ export async function forgetMemory(args) {
|
|
|
296
345
|
if (!row) {
|
|
297
346
|
return { title: "memory: not found", output: `No memory with id ${args.id}.` };
|
|
298
347
|
}
|
|
348
|
+
if (args.soft) {
|
|
349
|
+
// D68: hide instead of delete. The status flip is the whole action —
|
|
350
|
+
// retrieval and the inventory exclude retracted, the file stays put.
|
|
351
|
+
// One-way (D64): no un-hide; restoring the fact means saving it again.
|
|
352
|
+
try {
|
|
353
|
+
const mf = setStatus(args.id, "retracted");
|
|
354
|
+
upsertFromFile(mf);
|
|
355
|
+
return {
|
|
356
|
+
title: "memory: hidden",
|
|
357
|
+
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.` +
|
|
358
|
+
// D70: a retraction of an in-repo memory is a local working-tree
|
|
359
|
+
// edit like the delete — without a commit (and a push) the memory
|
|
360
|
+
// stands on every teammate's clone, so "hidden" would be a lie.
|
|
361
|
+
(isInRepoMemoryFile(row.file_path)
|
|
362
|
+
? ` 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.`
|
|
363
|
+
: ""),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
return { title: "memory: hide failed", output: e.message };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
299
370
|
// Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
|
|
300
371
|
// Only ENOENT counts as "deleted": on any other unlink failure the file
|
|
301
372
|
// is still the source of truth and would be re-indexed on next sync, so
|
|
@@ -318,8 +389,7 @@ export async function forgetMemory(args) {
|
|
|
318
389
|
// D64: forgetting an in-repo memory deletes a file inside the user's
|
|
319
390
|
// git working tree. Say so — the deletion should ride a commit, or the
|
|
320
391
|
// file silently diverges from what teammates have.
|
|
321
|
-
const inRepo = row.file_path
|
|
322
|
-
!row.file_path.startsWith(paths().root);
|
|
392
|
+
const inRepo = isInRepoMemoryFile(row.file_path);
|
|
323
393
|
return {
|
|
324
394
|
title: "memory: forgotten",
|
|
325
395
|
output: inRepo
|
package/docs/TEST-PLAN.md
CHANGED
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
|
|
74
74
|
## H. 已知问题观察
|
|
75
75
|
|
|
76
|
-
- [
|
|
76
|
+
- [x] better-sqlite3 在 Node 24 退出时偶发 crash(exit 134):D72 已把驱动升到 13(N-API 线);Linux Node 24.20 连续 3 次 `test-full` 114/114。Windows 真机仍需确认同一版本下无 `SIGABRT` / exit 134,且不丢数据。
|
|
77
77
|
- [ ] `memory_list` 默认只列 project scope 是否符合预期(Stone 已定保持现状)
|
|
78
78
|
|
|
79
79
|
---
|
package/docs/V2-DESIGN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenMemex — Design Document (protocol v0.2)
|
|
2
2
|
|
|
3
|
-
**Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–
|
|
3
|
+
**Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–D73 are settled; open questions are tracked at the end of this document.
|
|
4
4
|
**Author:** Stone, with 小沐
|
|
5
5
|
**Changelog vs v1:** incorporates round-3 review from Perplexity, Grok, Gemini, ChatGPT, DeepSeek.
|
|
6
6
|
Key changes: Design Principles section; `role` separated from `type`; two iron rules;
|
|
@@ -1444,6 +1444,121 @@ requirement: personal data never touches third-party services). Benchmarks to tr
|
|
|
1444
1444
|
"full list: open-memex sync-status" pointer) after D66's CLI-parity test
|
|
1445
1445
|
had dropped that coverage.
|
|
1446
1446
|
|
|
1447
|
+
- **D68 — memory visibility: the inventory is a protocol surface.** Group 3-C
|
|
1448
|
+
(design: `docs/memory-visibility-design.md` v2). `memory_list` output
|
|
1449
|
+
becomes a numbered, structured inventory (`[type] id=… created=…
|
|
1450
|
+
source=…`, state tags for project scopes; `created=` is when the fact
|
|
1451
|
+
was learned, `updated=` shown only when it moved materially later). The
|
|
1452
|
+
raw enums stay in the data; plain-language renderings live in the
|
|
1453
|
+
guidance surfaces (tool descriptions, MCP handshake, SKILL.md,
|
|
1454
|
+
distill-agents footer) so agents rephrase in the conversation's
|
|
1455
|
+
language. New input `include: "active" | "all"` (default `active`) —
|
|
1456
|
+
a deliberate behavior change: superseded versions and retracted /
|
|
1457
|
+
archived memories leave the default listing (audit view via `all`).
|
|
1458
|
+
Every listing states its true total; truncation is disclosed, never
|
|
1459
|
+
silent (the D66 lesson applied to this surface). `open-memex
|
|
1460
|
+
inventory` renders the same data layer as text (default) or JSON
|
|
1461
|
+
(`open-memex-inventory/1`); a report containing personal memories
|
|
1462
|
+
refuses to be written inside a git working tree without
|
|
1463
|
+
`--allow-personal`. `memory_forget` gains `soft` (CLI `forget
|
|
1464
|
+
--soft`): retraction as a first-class tool action — hidden from lists
|
|
1465
|
+
and search, file kept, still one-way per D64 (restoring the fact means
|
|
1466
|
+
saving it again; no un-hide, no undo, no recycle bin). Deletion stays
|
|
1467
|
+
a confirmed conversation: agents re-list before acting on a number
|
|
1468
|
+
(numbers are only valid for the listing just produced) and read the
|
|
1469
|
+
entry back before deleting. The HTML rendering of the same data layer
|
|
1470
|
+
follows as a separate change; auto-draft stays gated on the unlock
|
|
1471
|
+
conditions in the design doc.
|
|
1472
|
+
- **D73 — one user statement, one memory: the keyword hook hands off to the
|
|
1473
|
+
agent.** The keyword hook and the agent are two writers for one user turn,
|
|
1474
|
+
and only one of them knows the sentence is already stored. Observed
|
|
1475
|
+
2026-10-04: a single `remember this folder is for my spark tank ideas …
|
|
1476
|
+
shared memory across agents and employees` produced **three** memories — one
|
|
1477
|
+
`source: keyword` verbatim capture plus two `source: tool` paraphrases the
|
|
1478
|
+
agent wrote because it could see the chat transcript but not the store. The
|
|
1479
|
+
existing §3.4 guard could not catch it: token Jaccard scored those
|
|
1480
|
+
paraphrases 0.21–0.38 against the capture, below the 0.8 near-dup bar, and
|
|
1481
|
+
`open-memex audit` reported zero near-dup pairs, so the store looked healthy
|
|
1482
|
+
while holding three copies of one statement (retrieval then returns all
|
|
1483
|
+
three). Two mechanisms, deliberately layered:
|
|
1484
|
+
**(a) hand-off note** — `captureFromText` records each capture against the
|
|
1485
|
+
host session id (`chat.message` / v2 `prompt`, both of which carry one), and
|
|
1486
|
+
the system-context hook (`experimental.chat.system.transform` / v2
|
|
1487
|
+
`context`) pushes a short block naming what was already stored, delivered
|
|
1488
|
+
once per capture and pruned after the turn window. The agent can now avoid
|
|
1489
|
+
the duplicate write it had no way to see. In-memory only, like
|
|
1490
|
+
`injectedSessions`: never persisted, gone on restart.
|
|
1491
|
+
**(b) turn-echo guard** — `memory_add` additionally refuses a write that
|
|
1492
|
+
overlaps a `source: keyword`/`user` memory created in the same scope within
|
|
1493
|
+
the last 10 minutes, returning the stored id and pointing at
|
|
1494
|
+
`memory_supersede`. This is the deterministic backstop for the case where
|
|
1495
|
+
(a) is ignored. It is deliberately scoped and high-barred, because lexical
|
|
1496
|
+
overlap **cannot** separate an agent's gloss from a genuinely new fact: in
|
|
1497
|
+
the same measured set, the agent's gloss scored 0.211 and a distinct fact
|
|
1498
|
+
the same sentence happened to contain scored 0.214. So the bar sits at
|
|
1499
|
+
0.28 — wholesale re-saves of the captured sentence measure 0.29–0.36 and are
|
|
1500
|
+
refused; anything below is left to (a) and the agent's judgment, and a
|
|
1501
|
+
refusal always names the stored id so the caller can rephrase to the new
|
|
1502
|
+
part or supersede. Agent-authored `source: tool` saves keep the old 0.8
|
|
1503
|
+
near-dup *notice* and are never refused. The refused case is recoverable by
|
|
1504
|
+
construction (supersede, or a follow-up add carrying only the new
|
|
1505
|
+
information), and no D14-style masking, dedup, or lifecycle rule changes.
|
|
1506
|
+
What is deliberately NOT done: no fuzzy-matching upgrade (D73 measures why a
|
|
1507
|
+
better metric still cannot decide "new information"), no auto-merging of the
|
|
1508
|
+
agent's gloss into the capture, and no transcript store (the replay-distillation
|
|
1509
|
+
open question owns that).
|
|
1510
|
+
|
|
1511
|
+
- **D72 — the Node SQLite driver moves to the N-API line.** The D70 suite was stable on Windows and failed in shifting clusters on Linux under Node 24.20: individual CLI processes intermittently died with `SIGABRT` in `Statement::~Statement()` (`RemoveEnvironmentCleanupHook ... Assertion failed: (env) != nullptr`) and lost their stdout, so unrelated supersede / submit / inventory checks failed depending on which process the GC reaped. The work itself had always landed; only the exit path was broken. Root cause is upstream: Node 24.19 changed `node::ObjectWrap` cleanup-hook registration, and `better-sqlite3` <13 (raw `node::ObjectWrap`) can finalize a prepared statement from a GC callback with no Environment entered. `better-sqlite3` 13 is the N-API rewrite and removes that path; it still supports Node ≥22, and every API this repo uses (`Database`, `prepare`, `exec`, `pragma`-style PRAGMAs, `close`) is unchanged. The suite's crash-retry shim stays as belt-and-braces for older installs, but the shipped driver no longer needs it on current Node. Verified: three consecutive Linux `test-full` runs at 114/114 plus a 40-command add/list/search loop with zero aborts (was ~50% per process).
|
|
1512
|
+
|
|
1513
|
+
- **D71 — the scope seed is the folder opened, never a filesystem root.**
|
|
1514
|
+
OpenCode v1 hands the plugin two roots: `worktree` (the repo root — but
|
|
1515
|
+
the filesystem root itself, `C:\` or `/`, when the opened folder is not a
|
|
1516
|
+
git repo) and `directory` (the folder actually opened). The v1 adapter
|
|
1517
|
+
preferred `worktree`, so every non-git folder on a drive seeded its scope
|
|
1518
|
+
on the drive root and collapsed into one shared
|
|
1519
|
+
`project__workspace__<hash of the root>` bucket — a `c:\temp` project
|
|
1520
|
+
landing in `project__workspace__3dc8472eaebe` (seed `c:\`) is what
|
|
1521
|
+
surfaced it. `pickScopeRoot` (src/scope.ts) now takes `worktree` only when
|
|
1522
|
+
it is a real directory below the filesystem root, else `directory`. The
|
|
1523
|
+
repo-root behavior is unchanged: a real worktree still wins, so opening a
|
|
1524
|
+
repo subfolder keeps the repo's scope. Memories already written under a
|
|
1525
|
+
root-seeded key stay where they are (no auto-migration, per the existing
|
|
1526
|
+
rule); `open-memex scopes` shows the stray bucket and
|
|
1527
|
+
`open-memex migrate --from <old-key>` moves them. The v2 adapter already
|
|
1528
|
+
seeded on the opened directory and is unaffected.
|
|
1529
|
+
|
|
1530
|
+
- **D70 — visibility follow-ups: one number, one guard, one truth.** The
|
|
1531
|
+
3-C review of D68/D69 found the surfaces honest individually and
|
|
1532
|
+
inconsistent with each other. (1) **Every visibility surface discloses
|
|
1533
|
+
truncation.** `memory_list` did; `open-memex list` — the command a
|
|
1534
|
+
sceptical user runs first — silently showed 20 of 25. It now renders
|
|
1535
|
+
the same structured line as the tool and states how many entries it
|
|
1536
|
+
dropped, naming the command that shows everything. (2) **Numbers are
|
|
1537
|
+
unique within a listing.** Ordinals ran per scope, so a single
|
|
1538
|
+
`scope: both` output contained two `#1`s and "delete #3" was a guess;
|
|
1539
|
+
the counter now runs across every section of one listing (tool, CLI and
|
|
1540
|
+
the text report). (3) **A correction that touches the repo says so.**
|
|
1541
|
+
The hard delete already did (D64); the soft hide did not, so a
|
|
1542
|
+
retraction of an in-repo memory read as "hidden" while it was only a
|
|
1543
|
+
local working-tree edit — the team still had the memory. Both surfaces
|
|
1544
|
+
now name the commit and push. (4) **The report guard fails closed and
|
|
1545
|
+
exists once.** `isInsideWorkTree` ran `git -C` on the output's parent
|
|
1546
|
+
directory, so a not-yet-created directory made the check throw and
|
|
1547
|
+
report "not in a worktree" — a fail-open on the one path where personal
|
|
1548
|
+
memories would ride a commit; it now resolves the nearest existing
|
|
1549
|
+
ancestor, reports a missing directory as itself, and both formats share
|
|
1550
|
+
a single refusal helper instead of two copies. (5) **`open-memex-inventory/2`**
|
|
1551
|
+
drops the absolute `file` path: the JSON renderer exists to be handed to
|
|
1552
|
+
an agent, i.e. pasted into a cloud conversation, and it carried the
|
|
1553
|
+
user's home directory. (6) Smaller honesty fixes: an empty scope is
|
|
1554
|
+
reported as `(0)` instead of vanishing; only the HTML audit view loads
|
|
1555
|
+
the hidden rows themselves (text/json pay for counts); the page's
|
|
1556
|
+
"no network requests" claim is now enforced by a `default-src 'none'`
|
|
1557
|
+
CSP rather than asserted; and `countList`'s comment states its one known
|
|
1558
|
+
divergence (a self-healed dangling chain pointer) instead of claiming
|
|
1559
|
+
equivalence. Deliberately NOT done, pending a decision: HTML i18n and a
|
|
1560
|
+
size cap for very large stores.
|
|
1561
|
+
|
|
1447
1562
|
## Open Questions
|
|
1448
1563
|
|
|
1449
1564
|
_Some early questions are resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull). The entries below are open unless marked decided._
|
|
@@ -1463,7 +1578,7 @@ _Some early questions are resolved — see D10 (rename), D11 (type/role split),
|
|
|
1463
1578
|
|
|
1464
1579
|
- **Novice experience: first success and plain language (open, 2026-10-03):** from an AI-novice review. The install ends with instructions, not an experience: candidate fix is a 60-second magic moment — init offers to save one sample memory and immediately has the user's agent answer a question using it, so the user *sees* recall work before being asked to trust it. Same screen should state the privacy model in plain words (personal never leaves this machine, no cloud, detected secrets are masked — all already true, all currently buried in docs), and the first time a suspected secret is masked, the capture path should say so. Underneath: a plain-language layer over the state machine ("saved on this computer" / "shared with the team") with outbox/submit/promote vocabulary becoming the advanced view — this is the same vocabulary wall the PM/BA question above hits from the other side. **Status (2026-10-03): partially addressed** — `init` now states the privacy model in plain words (D56); the 60-second magic moment and the plain-language layer remain open.
|
|
1465
1580
|
|
|
1466
|
-
- **Memory visibility and correctability (open, 2026-10-03):** also from the novice review, and arguably the trust foundation for every other feature. A non-CLI user currently cannot answer "what does my assistant remember about me?" without reading Markdown files. Candidate directions: a human-readable inventory (local HTML export or equivalent) listing memories in plain language with provenance shown the way a person asks for it ("saved 3 days ago while you were discussing deploys in project X") and one-step forget/undo from the same view. Provenance fields already exist in frontmatter; this is presentation, not schema. Without it, proactive capture (above) will read to novices as surveillance rather than help — so this question probably gates the auto-draft posture decision in the capture question.
|
|
1581
|
+
- **Memory visibility and correctability (open, 2026-10-03):** also from the novice review, and arguably the trust foundation for every other feature. A non-CLI user currently cannot answer "what does my assistant remember about me?" without reading Markdown files. Candidate directions: a human-readable inventory (local HTML export or equivalent) listing memories in plain language with provenance shown the way a person asks for it ("saved 3 days ago while you were discussing deploys in project X") and one-step forget/undo from the same view. Provenance fields already exist in frontmatter; this is presentation, not schema. Without it, proactive capture (above) will read to novices as surveillance rather than help — so this question probably gates the auto-draft posture decision in the capture question. **Status (2026-10-03): decided (D68)** — the inventory ships as structured list + text/JSON report with conversational rendering taught to agents; "one-step forget/undo from the view" was deliberately not built (deletion stays a confirmed conversation; soft-hide added instead); the HTML rendering follows separately. Auto-draft remains gated on the unlock conditions in `docs/memory-visibility-design.md`.
|
|
1467
1582
|
|
|
1468
1583
|
---
|
|
1469
1584
|
|