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/src/distill-agents.ts
CHANGED
|
@@ -92,6 +92,13 @@ export function distillAgentsMarkdown(opts: DistillAgentsOptions): string {
|
|
|
92
92
|
// stated them ("tool"). Keep the rubric one voice across surfaces.
|
|
93
93
|
`- Mark captures you infer (rather than the user stating) with`,
|
|
94
94
|
` \`source: "inference"\` — provenance matters more than polish.`,
|
|
95
|
+
// D68: same voice for the visibility surface — opencode agents reading
|
|
96
|
+
// this footer never see the MCP handshake either.
|
|
97
|
+
`- When the user asks what you remember, list it conversationally`,
|
|
98
|
+
` (\`memory_list\` with scope both, or \`open-memex inventory\`) — plain`,
|
|
99
|
+
` words, not raw ids. Before deleting an entry they point at, read it`,
|
|
100
|
+
` back in full and confirm; deletion is permanent. Offer hiding`,
|
|
101
|
+
` (\`memory_forget\` soft / \`forget --soft\`) when they hesitate.`,
|
|
95
102
|
);
|
|
96
103
|
lines.push(``);
|
|
97
104
|
return lines.join("\n");
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,10 @@ import {
|
|
|
3
3
|
PLUGIN_ID,
|
|
4
4
|
bootstrapPlugin,
|
|
5
5
|
captureFromText,
|
|
6
|
+
captureHandoff,
|
|
6
7
|
injectOnce,
|
|
7
8
|
} from "./plugin-core.ts";
|
|
9
|
+
import { pickScopeRoot } from "./scope.ts";
|
|
8
10
|
import { makeTools } from "./tools/memory.ts";
|
|
9
11
|
import { v2Plugin } from "./opencode-v2.ts";
|
|
10
12
|
|
|
@@ -19,26 +21,32 @@ import { v2Plugin } from "./opencode-v2.ts";
|
|
|
19
21
|
* type-only (D58), and v2's decode is structural.
|
|
20
22
|
*/
|
|
21
23
|
const server: Plugin = async ({ worktree, directory }) => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
// v1's `worktree` is the filesystem root for non-git folders — never seed a
|
|
25
|
+
// scope on it (D71). `pickScopeRoot` falls back to the opened `directory`.
|
|
26
|
+
const state = bootstrapPlugin(pickScopeRoot(worktree, directory));
|
|
24
27
|
const tools = makeTools(() => state.scope, state.cfg);
|
|
25
28
|
|
|
26
29
|
return {
|
|
27
30
|
tool: tools,
|
|
28
31
|
|
|
29
|
-
async "chat.message"(
|
|
32
|
+
async "chat.message"(input, output) {
|
|
30
33
|
const parts = (output.parts ?? []) as Array<{ type: string; text?: string }>;
|
|
31
34
|
const text = parts
|
|
32
35
|
.map((p) => (p?.type === "text" ? p.text ?? "" : ""))
|
|
33
36
|
.filter(Boolean)
|
|
34
37
|
.join("\n");
|
|
35
|
-
captureFromText(text, state);
|
|
38
|
+
captureFromText(text, state, input.sessionID ?? "");
|
|
36
39
|
},
|
|
37
40
|
|
|
38
41
|
async "experimental.chat.system.transform"(input, output) {
|
|
39
|
-
|
|
42
|
+
const sessionID = input.sessionID ?? "";
|
|
43
|
+
injectOnce(sessionID, state, (block) => {
|
|
40
44
|
output.system.push(block);
|
|
41
45
|
});
|
|
46
|
+
// D73: runs on every request, so the note lands on the very turn whose
|
|
47
|
+
// message the hook just captured. Delivered once per capture.
|
|
48
|
+
const handoff = captureHandoff(sessionID, state);
|
|
49
|
+
if (handoff) output.system.push(handoff);
|
|
42
50
|
},
|
|
43
51
|
};
|
|
44
52
|
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML renderer for the inventory (D68 follow-up): a single local file
|
|
3
|
+
* a user opens in any browser to read what the store remembers. It
|
|
4
|
+
* renders the SAME data layer as the text/JSON formats
|
|
5
|
+
* (`src/inventory.ts`) — one source, three renderings, no drift.
|
|
6
|
+
*
|
|
7
|
+
* Design rules (3-C v2) enforced here:
|
|
8
|
+
* - Every byte of memory content is HTML-escaped. Memory text can come
|
|
9
|
+
* from an imported bundle or a pasted web page; under file:// an
|
|
10
|
+
* unescaped <script> would execute in the user's own browser.
|
|
11
|
+
* - The filter box is plain show/hide over server-rendered entries
|
|
12
|
+
* (no innerHTML, no network, no external resources).
|
|
13
|
+
* - History (superseded versions, retracted, archived) is a folded
|
|
14
|
+
* section with supersede-chain pointers — auditable, never mixed
|
|
15
|
+
* into "what is remembered now". At scale (>50 current entries) the
|
|
16
|
+
* scope sections fold too.
|
|
17
|
+
* - Read-only: no delete buttons, no server callback. Deletion stays a
|
|
18
|
+
* confirmed conversation; the page says so, with the id to quote.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
import { PERSONAL_SCOPE } from "./scope.ts";
|
|
24
|
+
import {
|
|
25
|
+
escapeHtml,
|
|
26
|
+
relativeTime,
|
|
27
|
+
reviewStateLabel,
|
|
28
|
+
sourceLabel,
|
|
29
|
+
} from "./retrieve/display.ts";
|
|
30
|
+
import type { InventoryData, InventoryRow } from "./inventory.ts";
|
|
31
|
+
|
|
32
|
+
const FOLD_THRESHOLD = 50;
|
|
33
|
+
|
|
34
|
+
function entryHtml(row: InventoryRow, now: number): string {
|
|
35
|
+
const meta: string[] = [escapeHtml(sourceLabel(row.source))];
|
|
36
|
+
const when = relativeTime(row.created_at, now);
|
|
37
|
+
if (when) meta.push(escapeHtml(when));
|
|
38
|
+
if (row.scope_key !== PERSONAL_SCOPE.key) {
|
|
39
|
+
meta.push(escapeHtml(reviewStateLabel(row.review_state)));
|
|
40
|
+
if (row.review_state !== "draft") {
|
|
41
|
+
meta.push(
|
|
42
|
+
row.file_path.replace(/\\/g, "/").includes("/.ai/open-memex/")
|
|
43
|
+
? "file is in the repo"
|
|
44
|
+
: "file is not in the repo yet",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (row.status !== "active") meta.push(escapeHtml(`marked ${row.status}`));
|
|
49
|
+
return ` <article class="entry">
|
|
50
|
+
<p class="body">${escapeHtml(row.content.trim())}</p>
|
|
51
|
+
<p class="meta">${meta.join(" · ")} · <span class="id">id ${escapeHtml(row.id)}</span></p>
|
|
52
|
+
</article>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hiddenEntryHtml(row: InventoryRow, now: number): string {
|
|
56
|
+
const when = relativeTime(row.created_at, now);
|
|
57
|
+
const chain = row.superseded_by
|
|
58
|
+
? ` · replaced by <span class="id">${escapeHtml(row.superseded_by)}</span>`
|
|
59
|
+
: "";
|
|
60
|
+
return ` <article class="entry hidden-entry">
|
|
61
|
+
<p class="body">${escapeHtml(row.content.trim())}</p>
|
|
62
|
+
<p class="meta">${escapeHtml(row.status)}${when ? ` · saved ${escapeHtml(when)}` : ""}${chain} · <span class="id">id ${escapeHtml(row.id)}</span></p>
|
|
63
|
+
</article>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sectionHtml(
|
|
67
|
+
title: string,
|
|
68
|
+
blurb: string,
|
|
69
|
+
entries: string,
|
|
70
|
+
count: number,
|
|
71
|
+
fold: boolean,
|
|
72
|
+
open: boolean,
|
|
73
|
+
): string {
|
|
74
|
+
if (fold) {
|
|
75
|
+
return ` <details${open ? " open" : ""}>
|
|
76
|
+
<summary>${escapeHtml(title)} <span class="count">${count}</span></summary>
|
|
77
|
+
<p class="blurb">${escapeHtml(blurb)}</p>
|
|
78
|
+
${entries}
|
|
79
|
+
</details>`;
|
|
80
|
+
}
|
|
81
|
+
return ` <section>
|
|
82
|
+
<h2>${escapeHtml(title)} <span class="count">${count}</span></h2>
|
|
83
|
+
<p class="blurb">${escapeHtml(blurb)}</p>
|
|
84
|
+
${entries || ' <p class="empty">(nothing here yet)</p>'}
|
|
85
|
+
</section>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderInventoryHtml(data: InventoryData): string {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const fold = data.total > FOLD_THRESHOLD;
|
|
91
|
+
const personalCount = data.scopes
|
|
92
|
+
.filter((s) => s.scopeKey === PERSONAL_SCOPE.key)
|
|
93
|
+
.reduce((n, s) => n + s.rows.length, 0);
|
|
94
|
+
|
|
95
|
+
const parts: string[] = [];
|
|
96
|
+
data.scopes.forEach((s, i) => {
|
|
97
|
+
const isPersonal = s.scopeKey === PERSONAL_SCOPE.key;
|
|
98
|
+
parts.push(
|
|
99
|
+
sectionHtml(
|
|
100
|
+
isPersonal ? "About you — personal" : `Project: ${s.projectName}`,
|
|
101
|
+
isPersonal
|
|
102
|
+
? "These describe you, apply in every project, and never leave this machine."
|
|
103
|
+
: "Project memories. Each entry says whether it is still only on this machine (the outbox) or has reached the repo.",
|
|
104
|
+
s.rows.map((r) => entryHtml(r, now)).join("\n"),
|
|
105
|
+
s.rows.length,
|
|
106
|
+
fold,
|
|
107
|
+
isPersonal || i === 0,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
if (data.outbox.length > 0) {
|
|
112
|
+
parts.push(
|
|
113
|
+
sectionHtml(
|
|
114
|
+
"Not in the repo yet — outbox drafts",
|
|
115
|
+
"These drafts exist only on this machine. They reach the repo through review (submit → promote), never automatically.",
|
|
116
|
+
data.outbox.map((r) => entryHtml(r, now)).join("\n"),
|
|
117
|
+
data.outbox.length,
|
|
118
|
+
fold,
|
|
119
|
+
false,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (data.hiddenEntries.length > 0) {
|
|
124
|
+
const h = data.hiddenEntries.map((r) => hiddenEntryHtml(r, now)).join("\n");
|
|
125
|
+
parts.push(` <details>
|
|
126
|
+
<summary>Replaced & hidden history <span class="count">${data.hiddenEntries.length}</span></summary>
|
|
127
|
+
<p class="blurb">Older versions and withdrawn memories. Kept so you can audit how a fact changed; none of this is used for recall.</p>
|
|
128
|
+
${h}
|
|
129
|
+
</details>`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return `<!doctype html>
|
|
133
|
+
<html lang="en">
|
|
134
|
+
<head>
|
|
135
|
+
<meta charset="utf-8">
|
|
136
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
137
|
+
<!-- D70: the page claims it makes no network requests - enforce it. Inline
|
|
138
|
+
style/script are the only allowances; anything else (a stray <img
|
|
139
|
+
src>, a remote font) is blocked by the browser, not just by our promise. -->
|
|
140
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
|
141
|
+
<title>What OpenMemex remembers</title>
|
|
142
|
+
<style>
|
|
143
|
+
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 44rem; padding: 0 1rem; color: #1a1a1a; background: #fdfdfb; }
|
|
144
|
+
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
|
|
145
|
+
h2, summary { font-size: 1.1rem; font-weight: 600; }
|
|
146
|
+
summary { cursor: pointer; margin: 1.2rem 0 0.4rem; }
|
|
147
|
+
.sub { color: #666; font-size: 0.9rem; margin-top: 0; }
|
|
148
|
+
.count { color: #888; font-weight: normal; font-size: 0.85em; }
|
|
149
|
+
.blurb { color: #555; font-size: 0.9rem; }
|
|
150
|
+
.entry { border-top: 1px solid #e5e2d9; padding: 0.75rem 0; }
|
|
151
|
+
.hidden-entry { opacity: 0.75; }
|
|
152
|
+
.body { white-space: pre-wrap; margin: 0 0 0.35rem; }
|
|
153
|
+
.meta { color: #666; font-size: 0.82rem; margin: 0; }
|
|
154
|
+
.id { font-family: ui-monospace, monospace; font-size: 0.78rem; }
|
|
155
|
+
.empty { color: #888; }
|
|
156
|
+
.howto { background: #f4f1e8; border-radius: 6px; padding: 0.75rem 1rem; font-size: 0.9rem; }
|
|
157
|
+
input[type="search"] { width: 100%; padding: 0.5rem; font-size: 1rem; margin: 1rem 0; box-sizing: border-box; }
|
|
158
|
+
.hidden { display: none; }
|
|
159
|
+
</style>
|
|
160
|
+
</head>
|
|
161
|
+
<body>
|
|
162
|
+
<h1>What OpenMemex remembers about you</h1>
|
|
163
|
+
<p class="sub">Generated ${escapeHtml(data.generatedAt)} · ${data.total} memories (${personalCount} personal, ${data.total - personalCount} project incl. outbox). Read-only report.</p>
|
|
164
|
+
<input type="search" id="filter" placeholder="Filter memories…" aria-label="Filter memories">
|
|
165
|
+
<div class="howto">
|
|
166
|
+
<strong>Checking my work:</strong> every entry shows where it came from.
|
|
167
|
+
“I inferred this” means I wrote it from context, not from your words — those are the ones to check.
|
|
168
|
+
To remove one, tell your agent <em>“forget the memory about …”</em> (quote the id if you like) and it will read the entry back before deleting it — deletion is permanent, there is no undo.
|
|
169
|
+
A memory that lives in a repo is only truly gone once the deletion is committed.
|
|
170
|
+
</div>
|
|
171
|
+
${parts.join("\n")}
|
|
172
|
+
<script>
|
|
173
|
+
var input = document.getElementById('filter');
|
|
174
|
+
input.addEventListener('input', function () {
|
|
175
|
+
var q = input.value.trim().toLowerCase();
|
|
176
|
+
var entries = document.querySelectorAll('.entry');
|
|
177
|
+
for (var i = 0; i < entries.length; i++) {
|
|
178
|
+
var el = entries[i];
|
|
179
|
+
el.classList.toggle('hidden', q !== '' && el.textContent.toLowerCase().indexOf(q) === -1);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
</script>
|
|
183
|
+
</body>
|
|
184
|
+
</html>
|
|
185
|
+
`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Default report location: the data dir, never a working tree. */
|
|
189
|
+
export function defaultHtmlOutPath(dataRoot: string): string {
|
|
190
|
+
return path.join(dataRoot, "inventory.html");
|
|
191
|
+
}
|
package/src/inventory.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `open-memex inventory` (D68): what the store remembers, in the open.
|
|
3
|
+
* One data layer, three renderers (text / json / html). The HTML
|
|
4
|
+
* renderer lives in inventory-html.ts; this module owns the data and
|
|
5
|
+
* the two renderers an agent or a terminal consumes directly.
|
|
6
|
+
*
|
|
7
|
+
* Design rules (3-C v2) enforced here:
|
|
8
|
+
* - Default scope is personal + the current project — "can't see it"
|
|
9
|
+
* is the disease this surface treats.
|
|
10
|
+
* - Nothing truncates silently: every section states its totals, and
|
|
11
|
+
* hidden history (superseded versions, retracted, archived) is
|
|
12
|
+
* counted and pointed at, never just omitted.
|
|
13
|
+
* - Drafts that have not reached the repo (the outbox) are their own
|
|
14
|
+
* section, consistent with what memory_status reports.
|
|
15
|
+
* - The report is read-only. Deleting stays a confirmed conversation.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { db } from "./store/db.ts";
|
|
19
|
+
import { PERSONAL_SCOPE } from "./scope.ts";
|
|
20
|
+
import { formatInventoryLine } from "./retrieve/display.ts";
|
|
21
|
+
|
|
22
|
+
export interface InventoryRow {
|
|
23
|
+
id: string;
|
|
24
|
+
scope_key: string;
|
|
25
|
+
project_name: string;
|
|
26
|
+
type: string;
|
|
27
|
+
status: string;
|
|
28
|
+
review_state: string;
|
|
29
|
+
source: string;
|
|
30
|
+
created_at: number;
|
|
31
|
+
updated_at: number;
|
|
32
|
+
content: string;
|
|
33
|
+
file_path: string;
|
|
34
|
+
superseded_by?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface InventoryData {
|
|
38
|
+
generatedAt: string;
|
|
39
|
+
/** Currently-remembered rows (active/deprecated), grouped by scope. */
|
|
40
|
+
scopes: Array<{ scopeKey: string; projectName: string; rows: InventoryRow[] }>;
|
|
41
|
+
/** Project drafts still in the outbox (not in the repo yet). */
|
|
42
|
+
outbox: InventoryRow[];
|
|
43
|
+
/** History that exists but is not "remembered": counted, not listed. */
|
|
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[];
|
|
51
|
+
total: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const BASE_COLS = `id, scope_key, project_name, type, status, review_state,
|
|
55
|
+
source, created_at, updated_at, content, file_path`;
|
|
56
|
+
|
|
57
|
+
/**
|
|
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.
|
|
64
|
+
*/
|
|
65
|
+
export function loadInventory(
|
|
66
|
+
scopeKeys: string[],
|
|
67
|
+
opts: { hidden?: "counts" | "entries" } = {},
|
|
68
|
+
): InventoryData {
|
|
69
|
+
const inList = scopeKeys.map(() => "?").join(",");
|
|
70
|
+
const current = db()
|
|
71
|
+
.prepare(
|
|
72
|
+
`SELECT ${BASE_COLS} FROM memories
|
|
73
|
+
WHERE scope_key IN (${inList}) AND status IN ('active', 'deprecated')
|
|
74
|
+
ORDER BY scope_key, updated_at DESC`,
|
|
75
|
+
)
|
|
76
|
+
.all(...scopeKeys) as unknown as InventoryRow[];
|
|
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
|
+
: [];
|
|
87
|
+
const hidden = { superseded: 0, retracted: 0, archived: 0 };
|
|
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
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const outbox = current.filter(
|
|
110
|
+
(r) => r.scope_key !== PERSONAL_SCOPE.key && r.review_state === "draft",
|
|
111
|
+
);
|
|
112
|
+
const outboxIds = new Set(outbox.map((r) => r.id));
|
|
113
|
+
|
|
114
|
+
const byScope = new Map<string, InventoryRow[]>();
|
|
115
|
+
for (const r of current) {
|
|
116
|
+
if (outboxIds.has(r.id)) continue;
|
|
117
|
+
const arr = byScope.get(r.scope_key) ?? [];
|
|
118
|
+
arr.push(r);
|
|
119
|
+
byScope.set(r.scope_key, arr);
|
|
120
|
+
}
|
|
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.
|
|
125
|
+
const orderedKeys = [
|
|
126
|
+
...scopeKeys.filter((k) => k === PERSONAL_SCOPE.key),
|
|
127
|
+
...scopeKeys.filter((k) => k !== PERSONAL_SCOPE.key).sort(),
|
|
128
|
+
];
|
|
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
|
+
});
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
generatedAt: new Date().toISOString(),
|
|
140
|
+
scopes,
|
|
141
|
+
outbox,
|
|
142
|
+
hidden,
|
|
143
|
+
hiddenEntries,
|
|
144
|
+
total: current.length,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function hiddenLine(data: InventoryData): string {
|
|
149
|
+
const { superseded, retracted, archived } = data.hidden;
|
|
150
|
+
if (superseded + retracted + archived === 0) return "";
|
|
151
|
+
return `Not shown: ${superseded} superseded version(s), ${retracted} retracted, ${archived} archived — inspect with \`open-memex list --include all\`.`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function entriesText(rows: InventoryRow[], startAt = 0): string {
|
|
155
|
+
if (rows.length === 0) return "(nothing remembered in this scope yet)";
|
|
156
|
+
return rows
|
|
157
|
+
.map((r, i) =>
|
|
158
|
+
formatInventoryLine(
|
|
159
|
+
startAt + i,
|
|
160
|
+
{ ...r, snippet: r.content },
|
|
161
|
+
PERSONAL_SCOPE.key,
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Plain-text rendering: diffable in any terminal or CI log. */
|
|
168
|
+
export function renderInventoryText(data: InventoryData): string {
|
|
169
|
+
const out: string[] = [];
|
|
170
|
+
out.push(`What OpenMemex remembers — ${data.generatedAt}`);
|
|
171
|
+
out.push(
|
|
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).`,
|
|
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;
|
|
177
|
+
for (const s of data.scopes) {
|
|
178
|
+
const title =
|
|
179
|
+
s.scopeKey === PERSONAL_SCOPE.key
|
|
180
|
+
? "About you — personal (never leaves this machine)"
|
|
181
|
+
: `Project: ${s.projectName}`;
|
|
182
|
+
out.push("", `## ${title} (${s.rows.length})`, entriesText(s.rows, seq));
|
|
183
|
+
seq += s.rows.length;
|
|
184
|
+
}
|
|
185
|
+
if (data.outbox.length > 0) {
|
|
186
|
+
out.push(
|
|
187
|
+
"",
|
|
188
|
+
`## Not in the repo yet — outbox drafts (${data.outbox.length})`,
|
|
189
|
+
"These exist only on this machine. They reach the repo through review (submit → promote), never automatically.",
|
|
190
|
+
entriesText(data.outbox, seq),
|
|
191
|
+
);
|
|
192
|
+
seq += data.outbox.length;
|
|
193
|
+
}
|
|
194
|
+
const hidden = hiddenLine(data);
|
|
195
|
+
if (hidden) out.push("", hidden);
|
|
196
|
+
out.push(
|
|
197
|
+
"",
|
|
198
|
+
"Read-only report. To remove a memory, tell your agent to forget it — it will read the entry back before deleting (deletion is permanent).",
|
|
199
|
+
);
|
|
200
|
+
return out.join("\n") + "\n";
|
|
201
|
+
}
|
|
202
|
+
|
|
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
|
+
*/
|
|
211
|
+
export function renderInventoryJson(data: InventoryData): string {
|
|
212
|
+
const entry = (r: InventoryRow) => ({
|
|
213
|
+
id: r.id,
|
|
214
|
+
scope: r.scope_key,
|
|
215
|
+
project: r.project_name,
|
|
216
|
+
type: r.type,
|
|
217
|
+
status: r.status,
|
|
218
|
+
review_state: r.review_state,
|
|
219
|
+
source: r.source,
|
|
220
|
+
created_at: new Date(r.created_at).toISOString(),
|
|
221
|
+
updated_at: new Date(r.updated_at).toISOString(),
|
|
222
|
+
content: r.content,
|
|
223
|
+
});
|
|
224
|
+
return (
|
|
225
|
+
JSON.stringify(
|
|
226
|
+
{
|
|
227
|
+
format: "open-memex-inventory/2",
|
|
228
|
+
generated_at: data.generatedAt,
|
|
229
|
+
total: data.total,
|
|
230
|
+
scopes: data.scopes.map((s) => ({
|
|
231
|
+
scope: s.scopeKey,
|
|
232
|
+
project: s.projectName,
|
|
233
|
+
memories: s.rows.map(entry),
|
|
234
|
+
})),
|
|
235
|
+
outbox: data.outbox.map(entry),
|
|
236
|
+
hidden: data.hidden,
|
|
237
|
+
},
|
|
238
|
+
null,
|
|
239
|
+
2,
|
|
240
|
+
) + "\n"
|
|
241
|
+
);
|
|
242
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -116,6 +116,15 @@ memory_resolve, memory_pr_status — always call them by these full names.
|
|
|
116
116
|
question into its concepts and try 2–3 phrasings per concept (synonyms, the
|
|
117
117
|
user's other language, shorter keyword forms), and check the other scope too,
|
|
118
118
|
before concluding nothing is stored.
|
|
119
|
+
- When the user asks what you remember (about them, or in this project),
|
|
120
|
+
call memory_list with scope=both and present it conversationally — turn
|
|
121
|
+
the structured fields into plain words (source=user → "you told me
|
|
122
|
+
this"; source=inference → "I inferred this, check me"), and don't read
|
|
123
|
+
raw ids aloud. If they ask to remove or hide an entry, re-run
|
|
124
|
+
memory_list first (numbers from an older listing may have shifted),
|
|
125
|
+
read the candidate back in full, and get a confirmation before calling
|
|
126
|
+
memory_forget: deletion is permanent. If they hesitate, offer soft=true
|
|
127
|
+
(hide it instead — still one-way, but the file survives).
|
|
119
128
|
- Memories default to this project's scope; use the personal scope for facts
|
|
120
129
|
about the user that hold across all projects. When a saved fact becomes
|
|
121
130
|
outdated, call memory_supersede (find the old memory's id with memory_search
|
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
|