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/cli.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { findDuplicates, supersede, setStatus } from "./store/lifecycle.ts";
|
|
|
9
9
|
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.ts";
|
|
10
10
|
import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.ts";
|
|
11
11
|
import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.ts";
|
|
12
|
-
import { search, list, hitStateLabel } from "./retrieve/search.ts";
|
|
12
|
+
import { search, list, countList, hitStateLabel } from "./retrieve/search.ts";
|
|
13
|
+
import { formatInventoryLine, truncationNote } from "./retrieve/display.ts";
|
|
13
14
|
import {
|
|
14
15
|
writeMemoryFile,
|
|
15
16
|
readMemoryFile,
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
type Frontmatter,
|
|
20
21
|
} from "./store/markdown.ts";
|
|
21
22
|
import { loadConfig, configSource } from "./config.ts";
|
|
22
|
-
import { paths, projectRoot } from "./paths.ts";
|
|
23
|
+
import { paths, projectRoot, isInRepoMemoryFile } from "./paths.ts";
|
|
23
24
|
import { redact } from "./redact.ts";
|
|
24
25
|
import { resolveMcpCommand } from "./init.ts";
|
|
25
26
|
import fs from "node:fs";
|
|
@@ -27,10 +28,22 @@ import path from "node:path";
|
|
|
27
28
|
import { execFileSync } from "node:child_process";
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* True when `target` sits inside a git working tree (D68 guard).
|
|
33
|
+
*
|
|
34
|
+
* D70: resolve the nearest EXISTING ancestor first. `git -C` on a
|
|
35
|
+
* directory that does not exist throws, and the old code turned that
|
|
36
|
+
* into "not in a worktree" — a fail-open guard on the one path where
|
|
37
|
+
* personal memories would ride a commit.
|
|
38
|
+
*/
|
|
31
39
|
function isInsideWorkTree(target: string): boolean {
|
|
32
40
|
try {
|
|
33
|
-
|
|
41
|
+
let dir = path.dirname(path.resolve(target));
|
|
42
|
+
while (!fs.existsSync(dir)) {
|
|
43
|
+
const parent = path.dirname(dir);
|
|
44
|
+
if (parent === dir) break;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
34
47
|
const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
|
|
35
48
|
stdio: ["ignore", "pipe", "ignore"],
|
|
36
49
|
encoding: "utf8",
|
|
@@ -41,6 +54,31 @@ function isInsideWorkTree(target: string): boolean {
|
|
|
41
54
|
}
|
|
42
55
|
}
|
|
43
56
|
|
|
57
|
+
/**
|
|
58
|
+
* D70: the one place that decides where a report may be written, for
|
|
59
|
+
* every `inventory` format. Returns an error message to print, or null
|
|
60
|
+
* when the path is safe. Kept single so the text/json/html paths cannot
|
|
61
|
+
* drift apart (they already had two copies, D68).
|
|
62
|
+
*/
|
|
63
|
+
function inventoryWriteRefusal(
|
|
64
|
+
target: string,
|
|
65
|
+
includesPersonal: boolean,
|
|
66
|
+
allowPersonal: boolean,
|
|
67
|
+
): string | null {
|
|
68
|
+
// Personal content must not ride a commit (the design's hardest rule).
|
|
69
|
+
if (includesPersonal && !allowPersonal && isInsideWorkTree(target)) {
|
|
70
|
+
return (
|
|
71
|
+
`inventory: ${target} is inside a git working tree and this report includes personal memories. ` +
|
|
72
|
+
`Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const parent = path.dirname(path.resolve(target));
|
|
76
|
+
if (!fs.existsSync(parent)) {
|
|
77
|
+
return `inventory: the directory ${parent} does not exist — create it first, or choose another --out path.`;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
44
82
|
/** Per-command help, printed by `open-memex <command> --help`.
|
|
45
83
|
AI assistants discover the CLI through --help, so every command needs one. */
|
|
46
84
|
const COMMAND_HELP: Record<string, string> = {
|
|
@@ -51,17 +89,21 @@ Usage: open-memex where
|
|
|
51
89
|
Example:
|
|
52
90
|
open-memex where`,
|
|
53
91
|
|
|
54
|
-
list: `List memories in a scope, newest first.
|
|
92
|
+
list: `List memories in a scope, newest first, as a numbered inventory.
|
|
55
93
|
|
|
56
94
|
Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
|
|
57
95
|
|
|
58
96
|
Flags:
|
|
59
97
|
--scope project (default), personal, or both
|
|
60
98
|
--type filter by memory type
|
|
61
|
-
--limit max results
|
|
99
|
+
--limit max results (default 20, max 100)
|
|
62
100
|
--include active (default: what is currently remembered) or all (audit
|
|
63
101
|
view: superseded versions, retracted and archived included)
|
|
64
102
|
|
|
103
|
+
A cut list says how many entries it left out. Numbers are unique within one
|
|
104
|
+
listing, so "the third one" names exactly one memory - but they are only
|
|
105
|
+
valid for the listing you just ran.
|
|
106
|
+
|
|
65
107
|
Examples:
|
|
66
108
|
open-memex list
|
|
67
109
|
open-memex list --scope personal --limit 20`,
|
|
@@ -118,7 +160,8 @@ Example:
|
|
|
118
160
|
|
|
119
161
|
forget: `Delete a memory by id. With --soft, hide it instead: the memory
|
|
120
162
|
is retracted (out of lists and search, file kept). Retraction is one-way —
|
|
121
|
-
save the content as a new memory to bring it back.
|
|
163
|
+
save the content as a new memory to bring it back. If the memory lives in
|
|
164
|
+
the repo, either action is only local until you commit and push it.
|
|
122
165
|
|
|
123
166
|
Usage: open-memex forget <id> [--soft]
|
|
124
167
|
|
|
@@ -179,16 +222,18 @@ Example:
|
|
|
179
222
|
inventory: `Show everything remembered — personal + this project — in the
|
|
180
223
|
open: what each memory says, where it came from, and whether it has reached
|
|
181
224
|
the repo yet. Read-only; deleting stays a confirmed conversation with your
|
|
182
|
-
agent. Formats: text (default, diffable)
|
|
225
|
+
agent. Formats: text (default, diffable), json (for agents), html (a local
|
|
226
|
+
page for your browser; default writes <data dir>/inventory.html).
|
|
183
227
|
|
|
184
|
-
Usage: open-memex inventory [--format text|json] [--scope project|personal|both] [--out <path>] [--allow-personal]
|
|
228
|
+
Usage: open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
|
|
185
229
|
|
|
186
|
-
Default scope: both. Without --out
|
|
230
|
+
Default scope: both. Without --out, text/json go to stdout. A report that
|
|
187
231
|
includes personal memories is refused inside a git working tree unless
|
|
188
232
|
--allow-personal is passed — personal content must not ride a commit.
|
|
189
233
|
|
|
190
234
|
Example:
|
|
191
|
-
open-memex inventory --format json --out ~/inventory.json
|
|
235
|
+
open-memex inventory --format json --out ~/inventory.json
|
|
236
|
+
open-memex inventory --format html`,
|
|
192
237
|
|
|
193
238
|
pull: `Pull shared project memories from the git remote: fetch + fast-forward
|
|
194
239
|
only. Never auto-merges — a diverged branch fails with a clear message and is
|
|
@@ -414,7 +459,7 @@ Usage:
|
|
|
414
459
|
open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
|
|
415
460
|
open-memex resolve [id-or-path]
|
|
416
461
|
open-memex sync-status
|
|
417
|
-
open-memex inventory [--format text|json] [--scope project|personal|both] [--out <path>] [--allow-personal]
|
|
462
|
+
open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
|
|
418
463
|
open-memex pull
|
|
419
464
|
open-memex push
|
|
420
465
|
open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
|
|
@@ -910,24 +955,41 @@ async function main() {
|
|
|
910
955
|
// D66: --scope both, on par with the memory_list tool — prints each
|
|
911
956
|
// scope's newest under its own header.
|
|
912
957
|
const scopes =
|
|
913
|
-
flags.scope === "both" ? [
|
|
958
|
+
flags.scope === "both" ? [PERSONAL_SCOPE, project] : [resolveCliScope(flags, project)];
|
|
959
|
+
const include = flags.include === "all" ? "all" : "active";
|
|
960
|
+
// D70: the CLI renders the SAME structured inventory the agent sees,
|
|
961
|
+
// numbering runs across the whole listing, and a cut list says how
|
|
962
|
+
// many entries it dropped — `open-memex list` is the surface a
|
|
963
|
+
// sceptical user runs first, so it may not look complete when it isn't.
|
|
964
|
+
let seq = 0;
|
|
914
965
|
for (const s of scopes) {
|
|
915
966
|
syncScope(s.key, "cli");
|
|
916
967
|
const hits = list(s.key, {
|
|
917
968
|
type: flags.type,
|
|
918
969
|
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
919
|
-
include
|
|
970
|
+
include,
|
|
920
971
|
});
|
|
921
|
-
|
|
972
|
+
const total = countList(s.key, { type: flags.type, include });
|
|
973
|
+
if (scopes.length > 1) {
|
|
974
|
+
console.log(
|
|
975
|
+
s.key === PERSONAL_SCOPE.key
|
|
976
|
+
? "## About you (personal — stays on this machine, applies everywhere)"
|
|
977
|
+
: `## This project (${s.projectName || s.key})`,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
922
980
|
if (hits.length === 0) {
|
|
923
|
-
console.log(
|
|
981
|
+
console.log(
|
|
982
|
+
scopes.length > 1
|
|
983
|
+
? "(nothing remembered in this scope yet)"
|
|
984
|
+
: `(no memories in ${s.key})`,
|
|
985
|
+
);
|
|
924
986
|
continue;
|
|
925
987
|
}
|
|
926
988
|
for (const h of hits) {
|
|
927
|
-
|
|
928
|
-
const rev = hitStateLabel(h);
|
|
929
|
-
console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
989
|
+
console.log(formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
|
|
930
990
|
}
|
|
991
|
+
const note = truncationNote(hits.length, total);
|
|
992
|
+
if (note) console.log(note);
|
|
931
993
|
}
|
|
932
994
|
return;
|
|
933
995
|
}
|
|
@@ -1110,10 +1172,20 @@ async function main() {
|
|
|
1110
1172
|
// D68: --soft hides (retracts) instead of deleting — same action as
|
|
1111
1173
|
// the memory_forget tool's soft flag. One-way (D64).
|
|
1112
1174
|
if (rest.includes("--soft")) {
|
|
1175
|
+
const target = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
|
|
1176
|
+
| { file_path: string }
|
|
1177
|
+
| undefined;
|
|
1113
1178
|
try {
|
|
1114
1179
|
const mf = setStatus(id, "retracted");
|
|
1115
1180
|
upsertFromFile(mf);
|
|
1116
1181
|
console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
|
|
1182
|
+
// D70: same honesty as the hard delete — a retraction of an in-repo
|
|
1183
|
+
// memory is a local working-tree edit until it is committed and pushed.
|
|
1184
|
+
if (target && isInRepoMemoryFile(target.file_path)) {
|
|
1185
|
+
console.log(
|
|
1186
|
+
`note: that memory lives in the repo (.ai/open-memex/) — the retraction is only local until you commit and push it; until then the team still sees it.`,
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1117
1189
|
} catch (e) {
|
|
1118
1190
|
console.error(`hide failed: ${(e as Error).message}`);
|
|
1119
1191
|
process.exit(1);
|
|
@@ -1260,8 +1332,8 @@ function positionalArgs(argv: string[]): string[] {
|
|
|
1260
1332
|
syncScope(PERSONAL_SCOPE.key, "cli");
|
|
1261
1333
|
const flags = parseFlags(rest);
|
|
1262
1334
|
const format = flags.format ?? "text";
|
|
1263
|
-
if (format !== "text" && format !== "json") {
|
|
1264
|
-
console.error(`inventory: unknown --format '${format}' (text|json
|
|
1335
|
+
if (format !== "text" && format !== "json" && format !== "html") {
|
|
1336
|
+
console.error(`inventory: unknown --format '${format}' (text|json|html)`);
|
|
1265
1337
|
process.exit(2);
|
|
1266
1338
|
}
|
|
1267
1339
|
const scopeKeys =
|
|
@@ -1271,26 +1343,38 @@ function positionalArgs(argv: string[]): string[] {
|
|
|
1271
1343
|
? [project.key]
|
|
1272
1344
|
: [PERSONAL_SCOPE.key, project.key];
|
|
1273
1345
|
const outPath = flags["out"] ?? flags["output"];
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
// refuse unless the user explicitly accepts that.
|
|
1277
|
-
if (
|
|
1278
|
-
outPath &&
|
|
1279
|
-
scopeKeys.includes(PERSONAL_SCOPE.key) &&
|
|
1280
|
-
flags["allow-personal"] !== "true" &&
|
|
1281
|
-
isInsideWorkTree(outPath)
|
|
1282
|
-
) {
|
|
1283
|
-
console.error(
|
|
1284
|
-
`inventory: ${outPath} is inside a git working tree and this report includes personal memories. ` +
|
|
1285
|
-
`Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`,
|
|
1286
|
-
);
|
|
1287
|
-
process.exit(2);
|
|
1288
|
-
}
|
|
1346
|
+
const includesPersonal = scopeKeys.includes(PERSONAL_SCOPE.key);
|
|
1347
|
+
const allowPersonal = flags["allow-personal"] === "true";
|
|
1289
1348
|
try {
|
|
1290
1349
|
const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.ts");
|
|
1350
|
+
if (format === "html") {
|
|
1351
|
+
const { renderInventoryHtml, defaultHtmlOutPath } = await import("./inventory-html.ts");
|
|
1352
|
+
const target = outPath ?? defaultHtmlOutPath(paths().root);
|
|
1353
|
+
// D70: one guard for every format (this path had its own copy).
|
|
1354
|
+
const refusal = inventoryWriteRefusal(target, includesPersonal, allowPersonal);
|
|
1355
|
+
if (refusal) {
|
|
1356
|
+
console.error(refusal);
|
|
1357
|
+
process.exit(2);
|
|
1358
|
+
}
|
|
1359
|
+
// D70: only the audit surface needs the hidden rows themselves;
|
|
1360
|
+
// text/json report counts, so they must not pay to load them.
|
|
1361
|
+
const data = loadInventory(scopeKeys, { hidden: "entries" });
|
|
1362
|
+
fs.writeFileSync(target, renderInventoryHtml(data), "utf8");
|
|
1363
|
+
console.log(`inventory: ${data.total} memories → ${target}`);
|
|
1364
|
+
console.log(`open it in any browser; the report is read-only.`);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1291
1367
|
const data = loadInventory(scopeKeys);
|
|
1292
1368
|
const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
|
|
1293
1369
|
if (outPath) {
|
|
1370
|
+
// D68/3-C v2: the report contains personal content. Writing it into
|
|
1371
|
+
// a git working tree would ride the next commit off this machine —
|
|
1372
|
+
// refuse unless the user explicitly accepts that.
|
|
1373
|
+
const refusal = inventoryWriteRefusal(outPath, includesPersonal, allowPersonal);
|
|
1374
|
+
if (refusal) {
|
|
1375
|
+
console.error(refusal);
|
|
1376
|
+
process.exit(2);
|
|
1377
|
+
}
|
|
1294
1378
|
fs.writeFileSync(outPath, rendered, "utf8");
|
|
1295
1379
|
console.log(`inventory: ${data.total} memories → ${outPath}`);
|
|
1296
1380
|
} else {
|
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
|
+
}
|