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/AGENTS.md +8 -4
- package/CHANGELOG.md +31 -0
- package/README.md +7 -1
- package/README.zh-CN.md +3 -1
- package/dist/.build-stamp +1 -1
- package/dist/capture/handoff.js +59 -0
- package/dist/cli.js +107 -33
- package/dist/index.js +14 -6
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +65 -27
- 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 +11 -3
- package/dist/retrieve/search.js +9 -3
- package/dist/scope.js +17 -0
- package/dist/store/lifecycle.js +42 -0
- package/dist/tools/ops.js +36 -8
- package/docs/TEST-PLAN.md +1 -1
- package/docs/V2-DESIGN.md +90 -1
- package/package.json +2 -2
- package/scripts/smoke-mcp.ts +17 -1
- package/scripts/smoke-pure.ts +63 -1
- package/scripts/test-full.ts +158 -1
- package/skills/open-memex/SKILL.md +9 -1
- package/src/capture/handoff.ts +74 -0
- package/src/cli.ts +120 -36
- package/src/index.ts +13 -5
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +78 -33
- 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 +11 -3
- package/src/retrieve/search.ts +9 -3
- package/src/scope.ts +18 -0
- package/src/store/lifecycle.ts +52 -0
- package/src/tools/ops.ts +38 -9
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
|
-
*
|
|
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(
|
|
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
|
|
64
|
-
.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
...
|
|
127
|
+
...scopeKeys.filter((k) => k !== PERSONAL_SCOPE.key).sort(),
|
|
94
128
|
];
|
|
95
|
-
const scopes = orderedKeys
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
/**
|
|
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/
|
|
227
|
+
format: "open-memex-inventory/2",
|
|
183
228
|
generated_at: data.generatedAt,
|
|
184
229
|
total: data.total,
|
|
185
230
|
scopes: data.scopes.map((s) => ({
|
package/src/opencode-v2.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/plugin-core.ts
CHANGED
|
@@ -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(
|
|
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
|
package/src/retrieve/display.ts
CHANGED
|
@@ -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
|
-
/**
|
|
163
|
-
|
|
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
|
}
|
package/src/retrieve/search.ts
CHANGED
|
@@ -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
|
|
270
|
-
* exactly the rows whose status is active/deprecated
|
|
271
|
-
*
|
|
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);
|
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,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 {
|
|
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
|
|
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:
|
|
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
|