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
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
import path from "node:path";
|
|
21
|
+
import { PERSONAL_SCOPE } from "./scope.js";
|
|
22
|
+
import { escapeHtml, relativeTime, reviewStateLabel, sourceLabel, } from "./retrieve/display.js";
|
|
23
|
+
const FOLD_THRESHOLD = 50;
|
|
24
|
+
function entryHtml(row, now) {
|
|
25
|
+
const meta = [escapeHtml(sourceLabel(row.source))];
|
|
26
|
+
const when = relativeTime(row.created_at, now);
|
|
27
|
+
if (when)
|
|
28
|
+
meta.push(escapeHtml(when));
|
|
29
|
+
if (row.scope_key !== PERSONAL_SCOPE.key) {
|
|
30
|
+
meta.push(escapeHtml(reviewStateLabel(row.review_state)));
|
|
31
|
+
if (row.review_state !== "draft") {
|
|
32
|
+
meta.push(row.file_path.replace(/\\/g, "/").includes("/.ai/open-memex/")
|
|
33
|
+
? "file is in the repo"
|
|
34
|
+
: "file is not in the repo yet");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (row.status !== "active")
|
|
38
|
+
meta.push(escapeHtml(`marked ${row.status}`));
|
|
39
|
+
return ` <article class="entry">
|
|
40
|
+
<p class="body">${escapeHtml(row.content.trim())}</p>
|
|
41
|
+
<p class="meta">${meta.join(" · ")} · <span class="id">id ${escapeHtml(row.id)}</span></p>
|
|
42
|
+
</article>`;
|
|
43
|
+
}
|
|
44
|
+
function hiddenEntryHtml(row, now) {
|
|
45
|
+
const when = relativeTime(row.created_at, now);
|
|
46
|
+
const chain = row.superseded_by
|
|
47
|
+
? ` · replaced by <span class="id">${escapeHtml(row.superseded_by)}</span>`
|
|
48
|
+
: "";
|
|
49
|
+
return ` <article class="entry hidden-entry">
|
|
50
|
+
<p class="body">${escapeHtml(row.content.trim())}</p>
|
|
51
|
+
<p class="meta">${escapeHtml(row.status)}${when ? ` · saved ${escapeHtml(when)}` : ""}${chain} · <span class="id">id ${escapeHtml(row.id)}</span></p>
|
|
52
|
+
</article>`;
|
|
53
|
+
}
|
|
54
|
+
function sectionHtml(title, blurb, entries, count, fold, open) {
|
|
55
|
+
if (fold) {
|
|
56
|
+
return ` <details${open ? " open" : ""}>
|
|
57
|
+
<summary>${escapeHtml(title)} <span class="count">${count}</span></summary>
|
|
58
|
+
<p class="blurb">${escapeHtml(blurb)}</p>
|
|
59
|
+
${entries}
|
|
60
|
+
</details>`;
|
|
61
|
+
}
|
|
62
|
+
return ` <section>
|
|
63
|
+
<h2>${escapeHtml(title)} <span class="count">${count}</span></h2>
|
|
64
|
+
<p class="blurb">${escapeHtml(blurb)}</p>
|
|
65
|
+
${entries || ' <p class="empty">(nothing here yet)</p>'}
|
|
66
|
+
</section>`;
|
|
67
|
+
}
|
|
68
|
+
export function renderInventoryHtml(data) {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const fold = data.total > FOLD_THRESHOLD;
|
|
71
|
+
const personalCount = data.scopes
|
|
72
|
+
.filter((s) => s.scopeKey === PERSONAL_SCOPE.key)
|
|
73
|
+
.reduce((n, s) => n + s.rows.length, 0);
|
|
74
|
+
const parts = [];
|
|
75
|
+
data.scopes.forEach((s, i) => {
|
|
76
|
+
const isPersonal = s.scopeKey === PERSONAL_SCOPE.key;
|
|
77
|
+
parts.push(sectionHtml(isPersonal ? "About you — personal" : `Project: ${s.projectName}`, isPersonal
|
|
78
|
+
? "These describe you, apply in every project, and never leave this machine."
|
|
79
|
+
: "Project memories. Each entry says whether it is still only on this machine (the outbox) or has reached the repo.", s.rows.map((r) => entryHtml(r, now)).join("\n"), s.rows.length, fold, isPersonal || i === 0));
|
|
80
|
+
});
|
|
81
|
+
if (data.outbox.length > 0) {
|
|
82
|
+
parts.push(sectionHtml("Not in the repo yet — outbox drafts", "These drafts exist only on this machine. They reach the repo through review (submit → promote), never automatically.", data.outbox.map((r) => entryHtml(r, now)).join("\n"), data.outbox.length, fold, false));
|
|
83
|
+
}
|
|
84
|
+
if (data.hiddenEntries.length > 0) {
|
|
85
|
+
const h = data.hiddenEntries.map((r) => hiddenEntryHtml(r, now)).join("\n");
|
|
86
|
+
parts.push(` <details>
|
|
87
|
+
<summary>Replaced & hidden history <span class="count">${data.hiddenEntries.length}</span></summary>
|
|
88
|
+
<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>
|
|
89
|
+
${h}
|
|
90
|
+
</details>`);
|
|
91
|
+
}
|
|
92
|
+
return `<!doctype html>
|
|
93
|
+
<html lang="en">
|
|
94
|
+
<head>
|
|
95
|
+
<meta charset="utf-8">
|
|
96
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
97
|
+
<!-- D70: the page claims it makes no network requests - enforce it. Inline
|
|
98
|
+
style/script are the only allowances; anything else (a stray <img
|
|
99
|
+
src>, a remote font) is blocked by the browser, not just by our promise. -->
|
|
100
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
|
101
|
+
<title>What OpenMemex remembers</title>
|
|
102
|
+
<style>
|
|
103
|
+
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 44rem; padding: 0 1rem; color: #1a1a1a; background: #fdfdfb; }
|
|
104
|
+
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
|
|
105
|
+
h2, summary { font-size: 1.1rem; font-weight: 600; }
|
|
106
|
+
summary { cursor: pointer; margin: 1.2rem 0 0.4rem; }
|
|
107
|
+
.sub { color: #666; font-size: 0.9rem; margin-top: 0; }
|
|
108
|
+
.count { color: #888; font-weight: normal; font-size: 0.85em; }
|
|
109
|
+
.blurb { color: #555; font-size: 0.9rem; }
|
|
110
|
+
.entry { border-top: 1px solid #e5e2d9; padding: 0.75rem 0; }
|
|
111
|
+
.hidden-entry { opacity: 0.75; }
|
|
112
|
+
.body { white-space: pre-wrap; margin: 0 0 0.35rem; }
|
|
113
|
+
.meta { color: #666; font-size: 0.82rem; margin: 0; }
|
|
114
|
+
.id { font-family: ui-monospace, monospace; font-size: 0.78rem; }
|
|
115
|
+
.empty { color: #888; }
|
|
116
|
+
.howto { background: #f4f1e8; border-radius: 6px; padding: 0.75rem 1rem; font-size: 0.9rem; }
|
|
117
|
+
input[type="search"] { width: 100%; padding: 0.5rem; font-size: 1rem; margin: 1rem 0; box-sizing: border-box; }
|
|
118
|
+
.hidden { display: none; }
|
|
119
|
+
</style>
|
|
120
|
+
</head>
|
|
121
|
+
<body>
|
|
122
|
+
<h1>What OpenMemex remembers about you</h1>
|
|
123
|
+
<p class="sub">Generated ${escapeHtml(data.generatedAt)} · ${data.total} memories (${personalCount} personal, ${data.total - personalCount} project incl. outbox). Read-only report.</p>
|
|
124
|
+
<input type="search" id="filter" placeholder="Filter memories…" aria-label="Filter memories">
|
|
125
|
+
<div class="howto">
|
|
126
|
+
<strong>Checking my work:</strong> every entry shows where it came from.
|
|
127
|
+
“I inferred this” means I wrote it from context, not from your words — those are the ones to check.
|
|
128
|
+
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.
|
|
129
|
+
A memory that lives in a repo is only truly gone once the deletion is committed.
|
|
130
|
+
</div>
|
|
131
|
+
${parts.join("\n")}
|
|
132
|
+
<script>
|
|
133
|
+
var input = document.getElementById('filter');
|
|
134
|
+
input.addEventListener('input', function () {
|
|
135
|
+
var q = input.value.trim().toLowerCase();
|
|
136
|
+
var entries = document.querySelectorAll('.entry');
|
|
137
|
+
for (var i = 0; i < entries.length; i++) {
|
|
138
|
+
var el = entries[i];
|
|
139
|
+
el.classList.toggle('hidden', q !== '' && el.textContent.toLowerCase().indexOf(q) === -1);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
</script>
|
|
143
|
+
</body>
|
|
144
|
+
</html>
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
/** Default report location: the data dir, never a working tree. */
|
|
148
|
+
export function defaultHtmlOutPath(dataRoot) {
|
|
149
|
+
return path.join(dataRoot, "inventory.html");
|
|
150
|
+
}
|
package/dist/inventory.js
CHANGED
|
@@ -20,29 +20,52 @@ import { formatInventoryLine } from "./retrieve/display.js";
|
|
|
20
20
|
const BASE_COLS = `id, scope_key, project_name, type, status, review_state,
|
|
21
21
|
source, created_at, updated_at, content, file_path`;
|
|
22
22
|
/**
|
|
23
|
-
* Load the inventory for the given scopes. `projectScopeKey` separates
|
|
24
|
-
*
|
|
23
|
+
* Load the inventory for the given scopes. `projectScopeKey` separates the
|
|
24
|
+
* outbox drafts (they belong to that scope's draft rows).
|
|
25
|
+
*
|
|
26
|
+
* D70: `hidden: "entries"` fetches the hidden rows themselves (HTML audit
|
|
27
|
+
* view); the default keeps the cheap COUNT query, because pulling every
|
|
28
|
+
* superseded body for a text report nobody reads is pure waste.
|
|
25
29
|
*/
|
|
26
|
-
export function loadInventory(scopeKeys) {
|
|
30
|
+
export function loadInventory(scopeKeys, opts = {}) {
|
|
27
31
|
const inList = scopeKeys.map(() => "?").join(",");
|
|
28
32
|
const current = db()
|
|
29
33
|
.prepare(`SELECT ${BASE_COLS} FROM memories
|
|
30
34
|
WHERE scope_key IN (${inList}) AND status IN ('active', 'deprecated')
|
|
31
35
|
ORDER BY scope_key, updated_at DESC`)
|
|
32
36
|
.all(...scopeKeys);
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const hiddenEntries = opts.hidden === "entries"
|
|
38
|
+
? db()
|
|
39
|
+
.prepare(`SELECT ${BASE_COLS}, superseded_by FROM memories
|
|
40
|
+
WHERE scope_key IN (${inList}) AND status IN ('superseded', 'retracted', 'archived')
|
|
41
|
+
ORDER BY updated_at DESC`)
|
|
42
|
+
.all(...scopeKeys)
|
|
43
|
+
: [];
|
|
38
44
|
const hidden = { superseded: 0, retracted: 0, archived: 0 };
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
if (hiddenEntries.length > 0) {
|
|
46
|
+
for (const r of hiddenEntries) {
|
|
47
|
+
if (r.status === "superseded")
|
|
48
|
+
hidden.superseded++;
|
|
49
|
+
if (r.status === "retracted")
|
|
50
|
+
hidden.retracted++;
|
|
51
|
+
if (r.status === "archived")
|
|
52
|
+
hidden.archived++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const counted = db()
|
|
57
|
+
.prepare(`SELECT status, COUNT(*) AS n FROM memories
|
|
58
|
+
WHERE scope_key IN (${inList}) AND status IN ('superseded', 'retracted', 'archived')
|
|
59
|
+
GROUP BY status`)
|
|
60
|
+
.all(...scopeKeys);
|
|
61
|
+
for (const r of counted) {
|
|
62
|
+
if (r.status === "superseded")
|
|
63
|
+
hidden.superseded = r.n;
|
|
64
|
+
if (r.status === "retracted")
|
|
65
|
+
hidden.retracted = r.n;
|
|
66
|
+
if (r.status === "archived")
|
|
67
|
+
hidden.archived = r.n;
|
|
68
|
+
}
|
|
46
69
|
}
|
|
47
70
|
const outbox = current.filter((r) => r.scope_key !== PERSONAL_SCOPE.key && r.review_state === "draft");
|
|
48
71
|
const outboxIds = new Set(outbox.map((r) => r.id));
|
|
@@ -55,14 +78,15 @@ export function loadInventory(scopeKeys) {
|
|
|
55
78
|
byScope.set(r.scope_key, arr);
|
|
56
79
|
}
|
|
57
80
|
// Personal first, then project scopes by key for a stable order.
|
|
81
|
+
// D70: every requested scope appears, including one with nothing in it —
|
|
82
|
+
// "this project has no memories" is part of the answer, and dropping the
|
|
83
|
+
// section made the report look like the project was never looked at.
|
|
58
84
|
const orderedKeys = [
|
|
59
85
|
...scopeKeys.filter((k) => k === PERSONAL_SCOPE.key),
|
|
60
|
-
...
|
|
86
|
+
...scopeKeys.filter((k) => k !== PERSONAL_SCOPE.key).sort(),
|
|
61
87
|
];
|
|
62
|
-
const scopes = orderedKeys
|
|
63
|
-
|
|
64
|
-
.map((k) => {
|
|
65
|
-
const rows = byScope.get(k);
|
|
88
|
+
const scopes = orderedKeys.map((k) => {
|
|
89
|
+
const rows = byScope.get(k) ?? [];
|
|
66
90
|
return {
|
|
67
91
|
scopeKey: k,
|
|
68
92
|
projectName: rows[0]?.project_name || k,
|
|
@@ -74,6 +98,7 @@ export function loadInventory(scopeKeys) {
|
|
|
74
98
|
scopes,
|
|
75
99
|
outbox,
|
|
76
100
|
hidden,
|
|
101
|
+
hiddenEntries,
|
|
77
102
|
total: current.length,
|
|
78
103
|
};
|
|
79
104
|
}
|
|
@@ -83,9 +108,11 @@ function hiddenLine(data) {
|
|
|
83
108
|
return "";
|
|
84
109
|
return `Not shown: ${superseded} superseded version(s), ${retracted} retracted, ${archived} archived — inspect with \`open-memex list --include all\`.`;
|
|
85
110
|
}
|
|
86
|
-
function entriesText(rows) {
|
|
111
|
+
function entriesText(rows, startAt = 0) {
|
|
112
|
+
if (rows.length === 0)
|
|
113
|
+
return "(nothing remembered in this scope yet)";
|
|
87
114
|
return rows
|
|
88
|
-
.map((r, i) => formatInventoryLine(i, { ...r, snippet: r.content }, PERSONAL_SCOPE.key))
|
|
115
|
+
.map((r, i) => formatInventoryLine(startAt + i, { ...r, snippet: r.content }, PERSONAL_SCOPE.key))
|
|
89
116
|
.join("\n");
|
|
90
117
|
}
|
|
91
118
|
/** Plain-text rendering: diffable in any terminal or CI log. */
|
|
@@ -93,14 +120,19 @@ export function renderInventoryText(data) {
|
|
|
93
120
|
const out = [];
|
|
94
121
|
out.push(`What OpenMemex remembers — ${data.generatedAt}`);
|
|
95
122
|
out.push(`${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).`);
|
|
123
|
+
// D70: one running counter across every section, so a number quoted back
|
|
124
|
+
// ("delete #3") names exactly one entry in this report.
|
|
125
|
+
let seq = 0;
|
|
96
126
|
for (const s of data.scopes) {
|
|
97
127
|
const title = s.scopeKey === PERSONAL_SCOPE.key
|
|
98
128
|
? "About you — personal (never leaves this machine)"
|
|
99
129
|
: `Project: ${s.projectName}`;
|
|
100
|
-
out.push("", `## ${title} (${s.rows.length})`, entriesText(s.rows));
|
|
130
|
+
out.push("", `## ${title} (${s.rows.length})`, entriesText(s.rows, seq));
|
|
131
|
+
seq += s.rows.length;
|
|
101
132
|
}
|
|
102
133
|
if (data.outbox.length > 0) {
|
|
103
|
-
out.push("", `## Not in the repo yet — outbox drafts (${data.outbox.length})`, "These exist only on this machine. They reach the repo through review (submit → promote), never automatically.", entriesText(data.outbox));
|
|
134
|
+
out.push("", `## Not in the repo yet — outbox drafts (${data.outbox.length})`, "These exist only on this machine. They reach the repo through review (submit → promote), never automatically.", entriesText(data.outbox, seq));
|
|
135
|
+
seq += data.outbox.length;
|
|
104
136
|
}
|
|
105
137
|
const hidden = hiddenLine(data);
|
|
106
138
|
if (hidden)
|
|
@@ -108,7 +140,14 @@ export function renderInventoryText(data) {
|
|
|
108
140
|
out.push("", "Read-only report. To remove a memory, tell your agent to forget it — it will read the entry back before deleting (deletion is permanent).");
|
|
109
141
|
return out.join("\n") + "\n";
|
|
110
142
|
}
|
|
111
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* JSON rendering: same data, for agents. Mirrors the export field names.
|
|
145
|
+
*
|
|
146
|
+
* D70: format `/2` drops the absolute `file` path. This artifact is meant
|
|
147
|
+
* to be handed to an agent — i.e. pasted into a cloud conversation — and it
|
|
148
|
+
* carried the user's home directory and name with it. The id locates the
|
|
149
|
+
* file (`open-memex where` prints the data root).
|
|
150
|
+
*/
|
|
112
151
|
export function renderInventoryJson(data) {
|
|
113
152
|
const entry = (r) => ({
|
|
114
153
|
id: r.id,
|
|
@@ -121,10 +160,9 @@ export function renderInventoryJson(data) {
|
|
|
121
160
|
created_at: new Date(r.created_at).toISOString(),
|
|
122
161
|
updated_at: new Date(r.updated_at).toISOString(),
|
|
123
162
|
content: r.content,
|
|
124
|
-
file: r.file_path,
|
|
125
163
|
});
|
|
126
164
|
return (JSON.stringify({
|
|
127
|
-
format: "open-memex-inventory/
|
|
165
|
+
format: "open-memex-inventory/2",
|
|
128
166
|
generated_at: data.generatedAt,
|
|
129
167
|
total: data.total,
|
|
130
168
|
scopes: data.scopes.map((s) => ({
|
package/dist/opencode-v2.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
|
|
2
|
+
import { PLUGIN_ID, bootstrapPlugin, captureFromText, captureHandoff, injectOnce, } from "./plugin-core.js";
|
|
3
3
|
import { makeTools } from "./tools/memory.js";
|
|
4
4
|
/** Register the five memory tools on a v2 host. */
|
|
5
5
|
async function registerTools(ctx, state) {
|
|
@@ -28,12 +28,17 @@ export const v2Plugin = {
|
|
|
28
28
|
const registrations = [];
|
|
29
29
|
await registerTools(ctx, state);
|
|
30
30
|
registrations.push(await ctx.session.hook("prompt", (ev) => {
|
|
31
|
-
captureFromText(ev.prompt?.text ?? "", state);
|
|
31
|
+
captureFromText(ev.prompt?.text ?? "", state, ev.sessionID ?? "");
|
|
32
32
|
}));
|
|
33
33
|
registrations.push(await ctx.session.hook("context", (ev) => {
|
|
34
|
-
|
|
34
|
+
const sessionID = ev.sessionID ?? "";
|
|
35
|
+
injectOnce(sessionID, state, (block) => {
|
|
35
36
|
ev.system.push({ type: "text", text: block });
|
|
36
37
|
});
|
|
38
|
+
// D73: tell the agent what the keyword hook already stored this turn.
|
|
39
|
+
const handoff = captureHandoff(sessionID, state);
|
|
40
|
+
if (handoff)
|
|
41
|
+
ev.system.push({ type: "text", text: handoff });
|
|
37
42
|
}));
|
|
38
43
|
return () => {
|
|
39
44
|
for (const r of registrations)
|
package/dist/paths.js
CHANGED
|
@@ -84,6 +84,24 @@ export function inRepoMemoriesDir(root, memoryDir) {
|
|
|
84
84
|
return dir;
|
|
85
85
|
}
|
|
86
86
|
/** Same as `inRepoMemoriesDir` but never creates the directory. */
|
|
87
|
+
/**
|
|
88
|
+
* True when a memory file lives inside the user's git working tree (the
|
|
89
|
+
* in-repo `.ai/open-memex/` copy) rather than in the local data root.
|
|
90
|
+
*
|
|
91
|
+
* D70: both destructive/corrective surfaces need this — the hard delete
|
|
92
|
+
* already said "commit the deletion so the team sees it" (D64), and the
|
|
93
|
+
* soft hide has to say the same, because a retraction that never gets
|
|
94
|
+
* committed leaves the memory standing on every teammate's clone.
|
|
95
|
+
*/
|
|
96
|
+
export function isInRepoMemoryFile(filePath) {
|
|
97
|
+
if (!filePath)
|
|
98
|
+
return false;
|
|
99
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
100
|
+
return normalized.includes("/.ai/open-memex/") && !normalized.startsWith(root());
|
|
101
|
+
}
|
|
102
|
+
function root() {
|
|
103
|
+
return paths().root.replace(/\\/g, "/");
|
|
104
|
+
}
|
|
87
105
|
export function inRepoMemoriesDirPath(root, memoryDir) {
|
|
88
106
|
return path.join(root, memoryDir);
|
|
89
107
|
}
|
package/dist/plugin-core.js
CHANGED
|
@@ -6,6 +6,7 @@ import { scopeHasFiles } from "./store/migrate.js";
|
|
|
6
6
|
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
|
|
7
7
|
import { buildContextBlock } from "./retrieve/inject.js";
|
|
8
8
|
import { detectKeywords } from "./capture/keywords.js";
|
|
9
|
+
import { formatHandoffBlock, freshCaptures, } from "./capture/handoff.js";
|
|
9
10
|
import { findDuplicates } from "./store/lifecycle.js";
|
|
10
11
|
import { redact } from "./redact.js";
|
|
11
12
|
/**
|
|
@@ -44,14 +45,16 @@ export function bootstrapPlugin(root) {
|
|
|
44
45
|
catch (err) {
|
|
45
46
|
console.error("[open-memex] init failed:", err);
|
|
46
47
|
}
|
|
47
|
-
return { cfg, scope, injectedSessions: new Set() };
|
|
48
|
+
return { cfg, scope, injectedSessions: new Set(), captures: new Map() };
|
|
48
49
|
}
|
|
49
50
|
/**
|
|
50
51
|
* Keyword capture (§ capture): scan one user message for "remember …"
|
|
51
52
|
* triggers and save each hit. Shared by the v1 `chat.message` hook and
|
|
52
|
-
* the v2 session `prompt` hook — both hand us the message text
|
|
53
|
+
* the v2 session `prompt` hook — both hand us the message text and the
|
|
54
|
+
* session id. The session id is what makes the D73 hand-off possible: without
|
|
55
|
+
* it the agent never learns the sentence is already stored.
|
|
53
56
|
*/
|
|
54
|
-
export function captureFromText(text, state) {
|
|
57
|
+
export function captureFromText(text, state, sessionID) {
|
|
55
58
|
const { cfg, scope } = state;
|
|
56
59
|
if (!cfg.keywordCaptureEnabled || !text)
|
|
57
60
|
return;
|
|
@@ -106,12 +109,54 @@ export function captureFromText(text, state) {
|
|
|
106
109
|
// offers no toast channel, so the plugin log is the feedback surface.
|
|
107
110
|
const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
|
|
108
111
|
console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
|
|
112
|
+
// D73 hand-off: the agent gets told about this capture in the turn's
|
|
113
|
+
// system context, so it does not save the same statement again.
|
|
114
|
+
recordCapture(state, sessionID, { id: fm.id, scopeKey: target.key, content, at: now });
|
|
109
115
|
}
|
|
110
116
|
catch (err) {
|
|
111
117
|
console.error("[open-memex] keyword capture failed:", err);
|
|
112
118
|
}
|
|
113
119
|
}
|
|
114
120
|
}
|
|
121
|
+
/** Note one hook capture against its session (D73). No-op without a session. */
|
|
122
|
+
function recordCapture(state, sessionID, record) {
|
|
123
|
+
if (!sessionID)
|
|
124
|
+
return;
|
|
125
|
+
const list = state.captures.get(sessionID) ?? [];
|
|
126
|
+
list.push(record);
|
|
127
|
+
state.captures.set(sessionID, list);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* D73 hand-off note (a): what the keyword hook already stored for this
|
|
131
|
+
* session, delivered once per capture into the turn's system context. The
|
|
132
|
+
* agent cannot avoid re-saving a statement it cannot see is stored, so the
|
|
133
|
+
* store tells it. Returns "" when there is nothing new to hand off; stale
|
|
134
|
+
* records are pruned (in-memory only, like `injectedSessions`).
|
|
135
|
+
*
|
|
136
|
+
* Both host adapters push this from their system/context hook — the one that
|
|
137
|
+
* runs before the model request, i.e. after `chat.message` / `prompt`.
|
|
138
|
+
*/
|
|
139
|
+
export function captureHandoff(sessionID, state) {
|
|
140
|
+
if (!sessionID)
|
|
141
|
+
return "";
|
|
142
|
+
const list = state.captures.get(sessionID);
|
|
143
|
+
if (!list || list.length === 0)
|
|
144
|
+
return "";
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const fresh = freshCaptures(list, now);
|
|
147
|
+
if (fresh.length !== list.length) {
|
|
148
|
+
if (fresh.length === 0)
|
|
149
|
+
state.captures.delete(sessionID);
|
|
150
|
+
else
|
|
151
|
+
state.captures.set(sessionID, fresh);
|
|
152
|
+
}
|
|
153
|
+
const pending = fresh.filter((r) => !r.delivered);
|
|
154
|
+
if (pending.length === 0)
|
|
155
|
+
return "";
|
|
156
|
+
for (const r of pending)
|
|
157
|
+
r.delivered = true;
|
|
158
|
+
return formatHandoffBlock(pending);
|
|
159
|
+
}
|
|
115
160
|
/**
|
|
116
161
|
* First-turn context injection: exactly once per session. The host adapter
|
|
117
162
|
* supplies `push` (v1: output.system.push; v2: the session context hook's
|
package/dist/retrieve/display.js
CHANGED
|
@@ -122,6 +122,10 @@ export function escapeHtml(s) {
|
|
|
122
122
|
* mouth ("delete #3"), the id is the machine contract. `created=` is
|
|
123
123
|
* when the memory was learned; `updated=` appears only when it moved
|
|
124
124
|
* materially later (supersede/promote touch updated_at).
|
|
125
|
+
*
|
|
126
|
+
* D70: `index` must be a running counter across every section of one
|
|
127
|
+
* listing — a per-section counter produces two "#1"s in a single
|
|
128
|
+
* `scope: both` output, which makes "delete #3" a guess.
|
|
125
129
|
*/
|
|
126
130
|
export function formatInventoryLine(index, hit, personalKey, now = Date.now()) {
|
|
127
131
|
const parts = [
|
|
@@ -145,9 +149,13 @@ export function formatInventoryLine(index, hit, personalKey, now = Date.now()) {
|
|
|
145
149
|
const body = hit.snippet.replace(/\s+/g, " ").trim();
|
|
146
150
|
return `${parts.join(" ")} — ${body}`;
|
|
147
151
|
}
|
|
148
|
-
/**
|
|
149
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Truncation disclosure (D68): a cut list always says so.
|
|
154
|
+
* D70: the wording names the surface that can show everything — the CLI —
|
|
155
|
+
* so a hidden entry is always reachable.
|
|
156
|
+
*/
|
|
157
|
+
export function truncationNote(shown, total, cli = "open-memex") {
|
|
150
158
|
if (total <= shown)
|
|
151
159
|
return null;
|
|
152
|
-
return `… and ${total - shown} more not shown (raise limit up to 100, or use memory_search to find something specific).`;
|
|
160
|
+
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).`;
|
|
153
161
|
}
|
package/dist/retrieve/search.js
CHANGED
|
@@ -189,9 +189,15 @@ export function list(scopeKey, opts = {}) {
|
|
|
189
189
|
}
|
|
190
190
|
/**
|
|
191
191
|
* True totals for the inventory's truncation disclosure (D68). Under
|
|
192
|
-
* `active` the visible set is the newest of each chain
|
|
193
|
-
* exactly the rows whose status is active/deprecated
|
|
194
|
-
*
|
|
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.
|
|
195
201
|
*/
|
|
196
202
|
export function countList(scopeKey, opts = {}) {
|
|
197
203
|
const typeFilter = opts.type ? ` AND type = ?` : "";
|
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();
|