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/dist/tools/ops.js
CHANGED
|
@@ -7,19 +7,19 @@
|
|
|
7
7
|
* result; each host adapts that to its own tool-result shape.
|
|
8
8
|
*/
|
|
9
9
|
import fs from "node:fs";
|
|
10
|
-
import path from "node:path";
|
|
11
10
|
import { z } from "zod";
|
|
12
11
|
import { PERSONAL_SCOPE } from "../scope.js";
|
|
13
12
|
import { search, list, countList, hitStateLabel } from "../retrieve/search.js";
|
|
14
13
|
import { formatInventoryLine, truncationNote } from "../retrieve/display.js";
|
|
15
14
|
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, normalizeAliases, } from "../store/markdown.js";
|
|
16
15
|
import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
|
|
17
|
-
import { findDuplicates, supersede, setStatus } from "../store/lifecycle.js";
|
|
16
|
+
import { findDuplicates, findTurnEcho, supersede, setStatus } from "../store/lifecycle.js";
|
|
17
|
+
import { TURN_ECHO_WINDOW_MS } from "../capture/handoff.js";
|
|
18
18
|
import { db } from "../store/db.js";
|
|
19
19
|
import { redact } from "../redact.js";
|
|
20
20
|
import { getSyncStatus, formatSyncStatus, submitMemories, outboxDraftCount } from "../submit.js";
|
|
21
21
|
import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.js";
|
|
22
|
-
import {
|
|
22
|
+
import { isInRepoMemoryFile } from "../paths.js";
|
|
23
23
|
import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory, } from "../review.js";
|
|
24
24
|
/**
|
|
25
25
|
* D53: push, don't poll. Append the project-outbox pending count to mutating
|
|
@@ -38,7 +38,7 @@ export async function withOutboxNote(scopeKey, p, reviewHint) {
|
|
|
38
38
|
}
|
|
39
39
|
/** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
|
|
40
40
|
export const TOOL_DESCRIPTIONS = {
|
|
41
|
-
memory_add: "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Worth saving: decisions and their reasons, preferences, conventions, gotchas, approaches tried and abandoned. Not worth saving: one-off task details or anything re-derivable from the code. Do not wait to be asked. For facts you infer yourself rather than the user stating, propose them first and save only on approval. Keep each memory to one self-contained statement; attach aliases when the parameter is available. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
|
|
41
|
+
memory_add: "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Worth saving: decisions and their reasons, preferences, conventions, gotchas, approaches tried and abandoned. Not worth saving: one-off task details or anything re-derivable from the code. Do not wait to be asked. For facts you infer yourself rather than the user stating, propose them first and save only on approval. Keep each memory to one self-contained statement; attach aliases when the parameter is available. If a note tells you the user's own wording was already stored verbatim this turn, that statement is already saved: do not retry it in a rewording — save only what that text does not contain, or use memory_supersede on the given id. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
|
|
42
42
|
memory_search: "Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or personal scope. Call before asking the user about past decisions, conventions, or preferences they may have told you before. Search well: break the question into its concepts and try 2–3 phrasings per concept — synonyms, the user's other language, shorter keyword forms — and check the other scope too, before concluding nothing is stored.",
|
|
43
43
|
memory_list: "List memories in a scope, newest first, as a numbered inventory. Each line keeps the raw fields — [type] id=… created=… source=… — say them back to the user in plain words (source=user → 'you told me this'; inference → 'I inferred this, check me'; keyword → 'caught from your own wording'), never read the raw id aloud unless they ask. When the user asks 'what do you remember about me?', use scope=both and present the inventory conversationally. The user may point at an entry by its number ('delete #3'): numbers are only valid for the listing you just produced — re-run memory_list, read the candidate back in full, and get a confirmation before calling memory_forget (deletion is permanent). include=all is the audit view (superseded versions, retracted, archived also shown).",
|
|
44
44
|
memory_supersede: "Replace an existing memory with a newer version. The old memory is kept as history (status: superseded) and retrieval returns the new one. Use when a saved fact becomes outdated and should be replaced rather than duplicated.",
|
|
@@ -198,6 +198,26 @@ export async function addMemory(getScope, cfg, args) {
|
|
|
198
198
|
output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
|
+
// D73: one user statement, one memory. The keyword hook stores the user's
|
|
202
|
+
// sentence verbatim with no agent in the loop; a paraphrase of that same
|
|
203
|
+
// sentence is refused instead of written beside it. The stored id comes back
|
|
204
|
+
// so the caller can supersede it or save only the genuinely new part.
|
|
205
|
+
const echo = findTurnEcho(s.key, redacted, TURN_ECHO_WINDOW_MS);
|
|
206
|
+
if (echo.length > 0) {
|
|
207
|
+
const top = echo[0];
|
|
208
|
+
const others = echo
|
|
209
|
+
.slice(1)
|
|
210
|
+
.map((e) => `id=${e.id} (${e.source}, overlap ${e.score.toFixed(2)})`)
|
|
211
|
+
.join(", ");
|
|
212
|
+
return {
|
|
213
|
+
title: "memory: already captured verbatim",
|
|
214
|
+
output: `Not saved — the user's own words were already stored moments ago (source: ${top.source}, overlap ${top.score.toFixed(2)}): ` +
|
|
215
|
+
`id=${top.id} — ${top.snippet}` +
|
|
216
|
+
(others ? `\nAlso matching: ${others}` : "") +
|
|
217
|
+
`\nDo not retry with a rewording: that only adds a second copy of one statement. ` +
|
|
218
|
+
`Save a separate memory only for information that stored text does not contain, or call memory_supersede on that id if this replaces it.`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
201
221
|
const fm = buildFrontmatter(s, {
|
|
202
222
|
type: args.type ?? "fact",
|
|
203
223
|
tags: args.tags ?? [],
|
|
@@ -247,10 +267,13 @@ export async function listMemories(getScope, args) {
|
|
|
247
267
|
// every section reports its true total — a truncated list says so
|
|
248
268
|
// instead of quietly looking complete (the D66 sync-status lesson).
|
|
249
269
|
const include = args.include ?? "active";
|
|
270
|
+
// D70: numbering runs across every section of THIS listing, so "delete
|
|
271
|
+
// #3" names exactly one memory. A per-section counter emitted two "#1"s.
|
|
272
|
+
let seq = 0;
|
|
250
273
|
const render = (scopeKey) => {
|
|
251
274
|
const hits = list(scopeKey, { type: args.type, limit: args.limit, include });
|
|
252
275
|
const total = countList(scopeKey, { type: args.type, include });
|
|
253
|
-
const lines = hits.map((h
|
|
276
|
+
const lines = hits.map((h) => formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
|
|
254
277
|
const note = truncationNote(hits.length, total);
|
|
255
278
|
if (note)
|
|
256
279
|
lines.push(note);
|
|
@@ -331,7 +354,13 @@ export async function forgetMemory(args) {
|
|
|
331
354
|
upsertFromFile(mf);
|
|
332
355
|
return {
|
|
333
356
|
title: "memory: hidden",
|
|
334
|
-
output: `Hidden ${args.id} (retracted). It no longer appears in lists or search, but the file is kept. This is one-way — to bring the fact back, save it again as a new memory
|
|
357
|
+
output: `Hidden ${args.id} (retracted). It no longer appears in lists or search, but the file is kept. This is one-way — to bring the fact back, save it again as a new memory.` +
|
|
358
|
+
// D70: a retraction of an in-repo memory is a local working-tree
|
|
359
|
+
// edit like the delete — without a commit (and a push) the memory
|
|
360
|
+
// stands on every teammate's clone, so "hidden" would be a lie.
|
|
361
|
+
(isInRepoMemoryFile(row.file_path)
|
|
362
|
+
? ` Note: this memory lives in the repo (.ai/open-memex/), so the retraction is only local until you commit and push it — until then the team still sees it.`
|
|
363
|
+
: ""),
|
|
335
364
|
};
|
|
336
365
|
}
|
|
337
366
|
catch (e) {
|
|
@@ -360,8 +389,7 @@ export async function forgetMemory(args) {
|
|
|
360
389
|
// D64: forgetting an in-repo memory deletes a file inside the user's
|
|
361
390
|
// git working tree. Say so — the deletion should ride a commit, or the
|
|
362
391
|
// file silently diverges from what teammates have.
|
|
363
|
-
const inRepo = row.file_path
|
|
364
|
-
!row.file_path.startsWith(paths().root);
|
|
392
|
+
const inRepo = isInRepoMemoryFile(row.file_path);
|
|
365
393
|
return {
|
|
366
394
|
title: "memory: forgotten",
|
|
367
395
|
output: inRepo
|
package/docs/TEST-PLAN.md
CHANGED
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
|
|
74
74
|
## H. 已知问题观察
|
|
75
75
|
|
|
76
|
-
- [
|
|
76
|
+
- [x] better-sqlite3 在 Node 24 退出时偶发 crash(exit 134):D72 已把驱动升到 13(N-API 线);Linux Node 24.20 连续 3 次 `test-full` 114/114。Windows 真机仍需确认同一版本下无 `SIGABRT` / exit 134,且不丢数据。
|
|
77
77
|
- [ ] `memory_list` 默认只列 project scope 是否符合预期(Stone 已定保持现状)
|
|
78
78
|
|
|
79
79
|
---
|
package/docs/V2-DESIGN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenMemex — Design Document (protocol v0.2)
|
|
2
2
|
|
|
3
|
-
**Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–
|
|
3
|
+
**Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–D73 are settled; open questions are tracked at the end of this document.
|
|
4
4
|
**Author:** Stone, with 小沐
|
|
5
5
|
**Changelog vs v1:** incorporates round-3 review from Perplexity, Grok, Gemini, ChatGPT, DeepSeek.
|
|
6
6
|
Key changes: Design Principles section; `role` separated from `type`; two iron rules;
|
|
@@ -1469,6 +1469,95 @@ requirement: personal data never touches third-party services). Benchmarks to tr
|
|
|
1469
1469
|
entry back before deleting. The HTML rendering of the same data layer
|
|
1470
1470
|
follows as a separate change; auto-draft stays gated on the unlock
|
|
1471
1471
|
conditions in the design doc.
|
|
1472
|
+
- **D73 — one user statement, one memory: the keyword hook hands off to the
|
|
1473
|
+
agent.** The keyword hook and the agent are two writers for one user turn,
|
|
1474
|
+
and only one of them knows the sentence is already stored. Observed
|
|
1475
|
+
2026-10-04: a single `remember this folder is for my spark tank ideas …
|
|
1476
|
+
shared memory across agents and employees` produced **three** memories — one
|
|
1477
|
+
`source: keyword` verbatim capture plus two `source: tool` paraphrases the
|
|
1478
|
+
agent wrote because it could see the chat transcript but not the store. The
|
|
1479
|
+
existing §3.4 guard could not catch it: token Jaccard scored those
|
|
1480
|
+
paraphrases 0.21–0.38 against the capture, below the 0.8 near-dup bar, and
|
|
1481
|
+
`open-memex audit` reported zero near-dup pairs, so the store looked healthy
|
|
1482
|
+
while holding three copies of one statement (retrieval then returns all
|
|
1483
|
+
three). Two mechanisms, deliberately layered:
|
|
1484
|
+
**(a) hand-off note** — `captureFromText` records each capture against the
|
|
1485
|
+
host session id (`chat.message` / v2 `prompt`, both of which carry one), and
|
|
1486
|
+
the system-context hook (`experimental.chat.system.transform` / v2
|
|
1487
|
+
`context`) pushes a short block naming what was already stored, delivered
|
|
1488
|
+
once per capture and pruned after the turn window. The agent can now avoid
|
|
1489
|
+
the duplicate write it had no way to see. In-memory only, like
|
|
1490
|
+
`injectedSessions`: never persisted, gone on restart.
|
|
1491
|
+
**(b) turn-echo guard** — `memory_add` additionally refuses a write that
|
|
1492
|
+
overlaps a `source: keyword`/`user` memory created in the same scope within
|
|
1493
|
+
the last 10 minutes, returning the stored id and pointing at
|
|
1494
|
+
`memory_supersede`. This is the deterministic backstop for the case where
|
|
1495
|
+
(a) is ignored. It is deliberately scoped and high-barred, because lexical
|
|
1496
|
+
overlap **cannot** separate an agent's gloss from a genuinely new fact: in
|
|
1497
|
+
the same measured set, the agent's gloss scored 0.211 and a distinct fact
|
|
1498
|
+
the same sentence happened to contain scored 0.214. So the bar sits at
|
|
1499
|
+
0.28 — wholesale re-saves of the captured sentence measure 0.29–0.36 and are
|
|
1500
|
+
refused; anything below is left to (a) and the agent's judgment, and a
|
|
1501
|
+
refusal always names the stored id so the caller can rephrase to the new
|
|
1502
|
+
part or supersede. Agent-authored `source: tool` saves keep the old 0.8
|
|
1503
|
+
near-dup *notice* and are never refused. The refused case is recoverable by
|
|
1504
|
+
construction (supersede, or a follow-up add carrying only the new
|
|
1505
|
+
information), and no D14-style masking, dedup, or lifecycle rule changes.
|
|
1506
|
+
What is deliberately NOT done: no fuzzy-matching upgrade (D73 measures why a
|
|
1507
|
+
better metric still cannot decide "new information"), no auto-merging of the
|
|
1508
|
+
agent's gloss into the capture, and no transcript store (the replay-distillation
|
|
1509
|
+
open question owns that).
|
|
1510
|
+
|
|
1511
|
+
- **D72 — the Node SQLite driver moves to the N-API line.** The D70 suite was stable on Windows and failed in shifting clusters on Linux under Node 24.20: individual CLI processes intermittently died with `SIGABRT` in `Statement::~Statement()` (`RemoveEnvironmentCleanupHook ... Assertion failed: (env) != nullptr`) and lost their stdout, so unrelated supersede / submit / inventory checks failed depending on which process the GC reaped. The work itself had always landed; only the exit path was broken. Root cause is upstream: Node 24.19 changed `node::ObjectWrap` cleanup-hook registration, and `better-sqlite3` <13 (raw `node::ObjectWrap`) can finalize a prepared statement from a GC callback with no Environment entered. `better-sqlite3` 13 is the N-API rewrite and removes that path; it still supports Node ≥22, and every API this repo uses (`Database`, `prepare`, `exec`, `pragma`-style PRAGMAs, `close`) is unchanged. The suite's crash-retry shim stays as belt-and-braces for older installs, but the shipped driver no longer needs it on current Node. Verified: three consecutive Linux `test-full` runs at 114/114 plus a 40-command add/list/search loop with zero aborts (was ~50% per process).
|
|
1512
|
+
|
|
1513
|
+
- **D71 — the scope seed is the folder opened, never a filesystem root.**
|
|
1514
|
+
OpenCode v1 hands the plugin two roots: `worktree` (the repo root — but
|
|
1515
|
+
the filesystem root itself, `C:\` or `/`, when the opened folder is not a
|
|
1516
|
+
git repo) and `directory` (the folder actually opened). The v1 adapter
|
|
1517
|
+
preferred `worktree`, so every non-git folder on a drive seeded its scope
|
|
1518
|
+
on the drive root and collapsed into one shared
|
|
1519
|
+
`project__workspace__<hash of the root>` bucket — a `c:\temp` project
|
|
1520
|
+
landing in `project__workspace__3dc8472eaebe` (seed `c:\`) is what
|
|
1521
|
+
surfaced it. `pickScopeRoot` (src/scope.ts) now takes `worktree` only when
|
|
1522
|
+
it is a real directory below the filesystem root, else `directory`. The
|
|
1523
|
+
repo-root behavior is unchanged: a real worktree still wins, so opening a
|
|
1524
|
+
repo subfolder keeps the repo's scope. Memories already written under a
|
|
1525
|
+
root-seeded key stay where they are (no auto-migration, per the existing
|
|
1526
|
+
rule); `open-memex scopes` shows the stray bucket and
|
|
1527
|
+
`open-memex migrate --from <old-key>` moves them. The v2 adapter already
|
|
1528
|
+
seeded on the opened directory and is unaffected.
|
|
1529
|
+
|
|
1530
|
+
- **D70 — visibility follow-ups: one number, one guard, one truth.** The
|
|
1531
|
+
3-C review of D68/D69 found the surfaces honest individually and
|
|
1532
|
+
inconsistent with each other. (1) **Every visibility surface discloses
|
|
1533
|
+
truncation.** `memory_list` did; `open-memex list` — the command a
|
|
1534
|
+
sceptical user runs first — silently showed 20 of 25. It now renders
|
|
1535
|
+
the same structured line as the tool and states how many entries it
|
|
1536
|
+
dropped, naming the command that shows everything. (2) **Numbers are
|
|
1537
|
+
unique within a listing.** Ordinals ran per scope, so a single
|
|
1538
|
+
`scope: both` output contained two `#1`s and "delete #3" was a guess;
|
|
1539
|
+
the counter now runs across every section of one listing (tool, CLI and
|
|
1540
|
+
the text report). (3) **A correction that touches the repo says so.**
|
|
1541
|
+
The hard delete already did (D64); the soft hide did not, so a
|
|
1542
|
+
retraction of an in-repo memory read as "hidden" while it was only a
|
|
1543
|
+
local working-tree edit — the team still had the memory. Both surfaces
|
|
1544
|
+
now name the commit and push. (4) **The report guard fails closed and
|
|
1545
|
+
exists once.** `isInsideWorkTree` ran `git -C` on the output's parent
|
|
1546
|
+
directory, so a not-yet-created directory made the check throw and
|
|
1547
|
+
report "not in a worktree" — a fail-open on the one path where personal
|
|
1548
|
+
memories would ride a commit; it now resolves the nearest existing
|
|
1549
|
+
ancestor, reports a missing directory as itself, and both formats share
|
|
1550
|
+
a single refusal helper instead of two copies. (5) **`open-memex-inventory/2`**
|
|
1551
|
+
drops the absolute `file` path: the JSON renderer exists to be handed to
|
|
1552
|
+
an agent, i.e. pasted into a cloud conversation, and it carried the
|
|
1553
|
+
user's home directory. (6) Smaller honesty fixes: an empty scope is
|
|
1554
|
+
reported as `(0)` instead of vanishing; only the HTML audit view loads
|
|
1555
|
+
the hidden rows themselves (text/json pay for counts); the page's
|
|
1556
|
+
"no network requests" claim is now enforced by a `default-src 'none'`
|
|
1557
|
+
CSP rather than asserted; and `countList`'s comment states its one known
|
|
1558
|
+
divergence (a self-healed dangling chain pointer) instead of claiming
|
|
1559
|
+
equivalence. Deliberately NOT done, pending a decision: HTML i18n and a
|
|
1560
|
+
size cap for very large stores.
|
|
1472
1561
|
|
|
1473
1562
|
## Open Questions
|
|
1474
1563
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-memex",
|
|
3
|
-
"version": "0.7.0
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
26
|
-
"better-sqlite3": "^
|
|
26
|
+
"better-sqlite3": "^13.0.3",
|
|
27
27
|
"js-yaml": "^4.1.0",
|
|
28
28
|
"zod": "^4.1.8"
|
|
29
29
|
},
|
package/scripts/smoke-mcp.ts
CHANGED
|
@@ -128,7 +128,23 @@ try {
|
|
|
128
128
|
check("memory_list keeps raw provenance field (D68)", listText.includes("source=tool"), listText.slice(0, 160));
|
|
129
129
|
check("memory_list shows age token (D68)", /created=(now|\d+[mhd])/.test(listText), listText.slice(0, 160));
|
|
130
130
|
const listBoth = await req("tools/call", { name: "memory_list", arguments: { scope: "both" } });
|
|
131
|
-
|
|
131
|
+
const listBothText = listBoth.result?.content?.[0]?.text ?? "";
|
|
132
|
+
check("memory_list both scopes (D68)", listBothText.includes("About you"));
|
|
133
|
+
// D70: numbers run across the whole listing, so "delete #3" names one memory.
|
|
134
|
+
// Needs a personal entry too, or there is only one section to number.
|
|
135
|
+
const personalAdd = await req("tools/call", {
|
|
136
|
+
name: "memory_add",
|
|
137
|
+
arguments: { content: "mcp e2e probe: a personal note for the both-scopes listing", type: "fact", scope: "personal" },
|
|
138
|
+
});
|
|
139
|
+
check("personal memory added for the both-scopes probe", (personalAdd.result?.content?.[0]?.text ?? "").includes("id="), (personalAdd.result?.content?.[0]?.text ?? "").slice(0, 120));
|
|
140
|
+
const listBoth2 = await req("tools/call", { name: "memory_list", arguments: { scope: "both" } });
|
|
141
|
+
const listBothText2 = listBoth2.result?.content?.[0]?.text ?? "";
|
|
142
|
+
const bothNums = [...listBothText2.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
|
|
143
|
+
check(
|
|
144
|
+
"memory_list numbers are unique across scopes (D70)",
|
|
145
|
+
bothNums.length > 1 && new Set(bothNums).size === bothNums.length && bothNums[0] === 1,
|
|
146
|
+
JSON.stringify(bothNums),
|
|
147
|
+
);
|
|
132
148
|
|
|
133
149
|
// D68: truncation is disclosed, never silent — one shown, total stated.
|
|
134
150
|
const add2 = await req("tools/call", {
|
package/scripts/smoke-pure.ts
CHANGED
|
@@ -5,10 +5,17 @@ import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
|
|
|
5
5
|
import { redact, findSecret } from "../src/redact.ts";
|
|
6
6
|
import { detectKeywords, scanKeywords } from "../src/capture/keywords.ts";
|
|
7
7
|
import { DEFAULT_CONFIG } from "../src/config.ts";
|
|
8
|
-
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
|
|
8
|
+
import { resolveProjectScope, resolveCwdScope, pickScopeRoot, PERSONAL_SCOPE } from "../src/scope.ts";
|
|
9
9
|
import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
|
|
10
10
|
import { toFtsQuery } from "../src/retrieve/query.ts";
|
|
11
11
|
import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
|
|
12
|
+
import {
|
|
13
|
+
TURN_ECHO_THRESHOLD,
|
|
14
|
+
TURN_ECHO_WINDOW_MS,
|
|
15
|
+
isTurnEcho,
|
|
16
|
+
freshCaptures,
|
|
17
|
+
formatHandoffBlock,
|
|
18
|
+
} from "../src/capture/handoff.ts";
|
|
12
19
|
import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, mergeV2PluginEntry, removeV2PluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
|
|
13
20
|
import { z } from "zod";
|
|
14
21
|
import { memoryAddArgs } from "../src/tools/ops.ts";
|
|
@@ -379,6 +386,17 @@ ok("cwd scope deterministic", cwdOnly.key === cwdOnly2.key);
|
|
|
379
386
|
// (This repo does have an origin after `git push`.)
|
|
380
387
|
console.log(" cwd.key =", cwdOnly.key);
|
|
381
388
|
|
|
389
|
+
console.log("== scope (pickScopeRoot, D71) ==");
|
|
390
|
+
// OpenCode v1 reports the filesystem root as `worktree` for non-git folders;
|
|
391
|
+
// seeding a scope on it merges every non-git folder on the drive into one
|
|
392
|
+
// shared `project__workspace__…` bucket. A real worktree still wins.
|
|
393
|
+
const fsRoot = path.parse(process.cwd()).root;
|
|
394
|
+
ok("real worktree wins", pickScopeRoot(process.cwd(), "/elsewhere/opened") === process.cwd());
|
|
395
|
+
ok("fs-root worktree yields to directory", pickScopeRoot(fsRoot, process.cwd()) === process.cwd());
|
|
396
|
+
ok("missing worktree uses directory", pickScopeRoot(null, process.cwd()) === process.cwd());
|
|
397
|
+
ok("nothing but fs root: last resort", pickScopeRoot(fsRoot, null) === fsRoot);
|
|
398
|
+
ok("no candidates falls back to cwd", pickScopeRoot(null, null) === process.cwd());
|
|
399
|
+
|
|
382
400
|
console.log("== cjk ==");
|
|
383
401
|
ok("detects han", hasCjk("中文记忆"));
|
|
384
402
|
ok("detects mixed", hasCjk("open中文"));
|
|
@@ -604,6 +622,50 @@ const near = similarity(
|
|
|
604
622
|
ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
|
|
605
623
|
ok("similarity empty → 0", similarity("", "anything") === 0);
|
|
606
624
|
|
|
625
|
+
console.log("== handoff (turn echo, D73) ==");
|
|
626
|
+
// One "remember ..." sentence produced three memories on 2026-10-04: the
|
|
627
|
+
// hook's verbatim capture plus two agent paraphrases. The hook capture is
|
|
628
|
+
// what the store already holds; the guard must refuse a re-save of it and
|
|
629
|
+
// must NOT eat a genuinely new fact the same sentence carried.
|
|
630
|
+
const HOOK_CAPTURE =
|
|
631
|
+
"this folder is for my spark tank ideas for ipipeline. I have ideas for AI Agents and shared memory/knowledge accross agents and employees.";
|
|
632
|
+
const reworded = [
|
|
633
|
+
`User's iPipeline spark-tank themes: AI Agents, and shared memory/knowledge across agents and across employees.`,
|
|
634
|
+
`This folder (C:\\temp\\AI_Inovation) is the user's "spark tank" for iPipeline ideas`,
|
|
635
|
+
"Planned work in this folder: AI Agents plus shared memory/knowledge across agents and employees",
|
|
636
|
+
];
|
|
637
|
+
const newFacts = [
|
|
638
|
+
"Deploy smoke tests live in tests/smoke/deploy.spec.ts and need a staging token",
|
|
639
|
+
"The staging cluster runs 3 nodes in us-east-1",
|
|
640
|
+
"Never deploy on Fridays after 15:00 UTC",
|
|
641
|
+
];
|
|
642
|
+
for (const r of reworded) {
|
|
643
|
+
const s = similarity(HOOK_CAPTURE, r);
|
|
644
|
+
ok(`refuses a re-save (${s.toFixed(2)} >= ${TURN_ECHO_THRESHOLD})`, isTurnEcho(s));
|
|
645
|
+
}
|
|
646
|
+
for (const f of newFacts) {
|
|
647
|
+
const s = similarity(HOOK_CAPTURE, f);
|
|
648
|
+
ok(`keeps a new fact (${s.toFixed(2)} < ${TURN_ECHO_THRESHOLD})`, !isTurnEcho(s));
|
|
649
|
+
}
|
|
650
|
+
ok("threshold is inclusive", isTurnEcho(TURN_ECHO_THRESHOLD));
|
|
651
|
+
ok("below threshold passes", !isTurnEcho(TURN_ECHO_THRESHOLD - 0.001));
|
|
652
|
+
|
|
653
|
+
const now = 1_700_000_000_000;
|
|
654
|
+
const rec = { id: "01ABC", scopeKey: "project__demo__0123456789ab", content: HOOK_CAPTURE, at: now };
|
|
655
|
+
ok("fresh inside window", freshCaptures([rec], now + 1000).length === 1);
|
|
656
|
+
ok("stale past window", freshCaptures([rec], now + TURN_ECHO_WINDOW_MS + 1).length === 0);
|
|
657
|
+
ok("no records → no block", formatHandoffBlock([]) === "");
|
|
658
|
+
const block = formatHandoffBlock([rec]);
|
|
659
|
+
ok("block names the id", block.includes("01ABC"));
|
|
660
|
+
ok("block shows the stored text", block.includes("spark tank"));
|
|
661
|
+
ok("block says do not re-add", block.includes("Do not call memory_add"));
|
|
662
|
+
ok("block points at supersede", block.includes("memory_supersede"));
|
|
663
|
+
ok(
|
|
664
|
+
"block collapses whitespace",
|
|
665
|
+
!formatHandoffBlock([{ ...rec, content: "a\n\n b" }]).includes("\n\n"),
|
|
666
|
+
);
|
|
667
|
+
ok("window is 10 minutes", TURN_ECHO_WINDOW_MS === 10 * 60 * 1000);
|
|
668
|
+
|
|
607
669
|
console.log("== init --global: userMcpConfigPath / mergeServerEntry ==");
|
|
608
670
|
// D45: user-level MCP config locations, per platform (platform param is injectable).
|
|
609
671
|
ok("vscode win32", userMcpConfigPath("vscode", "win32").endsWith(path.join("Code", "User", "mcp.json")));
|
package/scripts/test-full.ts
CHANGED
|
@@ -320,7 +320,7 @@ ok("inventory text lists the personal memory", r.out.includes("fulltest inventor
|
|
|
320
320
|
ok("inventory text keeps structured provenance", /source=cli/.test(r.out));
|
|
321
321
|
const invJson = cliRetry(["inventory", "--format", "json"], PROJ);
|
|
322
322
|
const invData = JSON.parse(invJson.out.slice(invJson.out.indexOf("{")));
|
|
323
|
-
ok("inventory json is the shared data layer", invData.format === "open-memex-inventory/
|
|
323
|
+
ok("inventory json is the shared data layer", invData.format === "open-memex-inventory/2" && invData.total >= 1 && Array.isArray(invData.scopes), invData.format);
|
|
324
324
|
const invFile = path.join(T, "inventory.json");
|
|
325
325
|
r = cliRetry(["inventory", "--format", "json", "--out", invFile], PROJ);
|
|
326
326
|
ok("inventory --out writes the file", r.code === 0 && fs.existsSync(invFile), (r.err || r.out).slice(0, 150));
|
|
@@ -341,6 +341,22 @@ r = cliRetry(["list", "--include", "all"], PROJ);
|
|
|
341
341
|
ok("hidden memory visible with --include all", r.out.includes(softId) && r.out.includes("[retracted]"), r.out.slice(0, 160));
|
|
342
342
|
r = cliRetry(["forget", softId], PROJ);
|
|
343
343
|
ok("hard forget still deletes a hidden memory", r.code === 0 && /deleted/.test(r.out));
|
|
344
|
+
// D68 follow-up: the HTML rendering of the same data layer.
|
|
345
|
+
const htmlId = addMem(["fulltest html page entry"], PROJ);
|
|
346
|
+
cliRetry(["forget", htmlId, "--soft"], PROJ);
|
|
347
|
+
const htmlFile = path.join(T, "inventory-page.html");
|
|
348
|
+
r = cliRetry(["inventory", "--format", "html", "--out", htmlFile], PROJ);
|
|
349
|
+
ok("inventory --format html writes the page", r.code === 0 && fs.existsSync(htmlFile), (r.err || r.out).slice(0, 150));
|
|
350
|
+
const page = fs.readFileSync(htmlFile, "utf8");
|
|
351
|
+
ok("html page shows current memories", page.includes("fulltest inventory entry"));
|
|
352
|
+
ok("html page escapes memory content", page.includes("<script>alert(1)</script>") && !page.includes("<script>alert(1)</script>"));
|
|
353
|
+
ok("html page folds the hidden history", page.includes("Replaced & hidden history") && page.includes(htmlId) && page.includes("retracted"));
|
|
354
|
+
ok("html page enforces 'no network' with a CSP", /Content-Security-Policy/.test(page) && /default-src 'none'/.test(page));
|
|
355
|
+
r = cliRetry(["inventory", "--format", "html"], PROJ);
|
|
356
|
+
ok("html defaults to the data dir", r.code === 0 && fs.existsSync(path.join(T, "data", "inventory.html")), (r.err || r.out).slice(0, 150));
|
|
357
|
+
const inTreePage = path.join(PROJ, "page.html");
|
|
358
|
+
r = cliRetry(["inventory", "--format", "html", "--out", inTreePage], PROJ);
|
|
359
|
+
ok("html refuses a worktree path without --allow-personal", r.code !== 0 && !fs.existsSync(inTreePage));
|
|
344
360
|
|
|
345
361
|
// ---------- 8. pull / push ----------
|
|
346
362
|
console.log("== pull / push ==");
|
|
@@ -612,6 +628,147 @@ console.log("== D64: p1 fixes (propose visibility / supersede published / status
|
|
|
612
628
|
ok("uninstall finds and cleans .jsonc global config", r.code === 0 && !/open-memex/.test(afterText), (r.out + r.err).slice(0, 160) + " || " + afterText.slice(0, 120));
|
|
613
629
|
}
|
|
614
630
|
|
|
631
|
+
// ---------- 12. D70: the visibility fixes from the 3-C review ----------
|
|
632
|
+
// Last section on purpose: nothing here may perturb the flows above it.
|
|
633
|
+
// It writes only into a scratch project and into appdata fixtures (no
|
|
634
|
+
// commits in PROJ, no extra CLI spawns beyond a handful) so the suite stays
|
|
635
|
+
// order-independent and cheap — the shared PROJ git state is never dirtied.
|
|
636
|
+
console.log("== D70: honest counts, stable numbers, one guard ==");
|
|
637
|
+
{
|
|
638
|
+
const fixture = (dir: string, id: string, body: string, scopeKey: string) => {
|
|
639
|
+
const now = new Date().toISOString();
|
|
640
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
641
|
+
fs.writeFileSync(
|
|
642
|
+
path.join(dir, `${id}.md`),
|
|
643
|
+
`---
|
|
644
|
+
id: ${id}
|
|
645
|
+
schema_version: 2
|
|
646
|
+
scope_key: ${scopeKey}
|
|
647
|
+
scope: project
|
|
648
|
+
visibility: internal
|
|
649
|
+
project_name: d70
|
|
650
|
+
type: fact
|
|
651
|
+
role: knowledge
|
|
652
|
+
importance: normal
|
|
653
|
+
status: active
|
|
654
|
+
tags: []
|
|
655
|
+
source: cli
|
|
656
|
+
created_at: "${now}"
|
|
657
|
+
updated_at: "${now}"
|
|
658
|
+
supersedes: null
|
|
659
|
+
superseded_by: null
|
|
660
|
+
review_state: draft
|
|
661
|
+
proposed_by: null
|
|
662
|
+
approved_by: null
|
|
663
|
+
derived_from: null
|
|
664
|
+
review_note: null
|
|
665
|
+
review_history: []
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
${body}
|
|
669
|
+
`,
|
|
670
|
+
"utf8",
|
|
671
|
+
);
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
// The guard must not fail open: a --out whose parent directory does not
|
|
675
|
+
// exist used to skip the worktree check (git -C on a missing dir throws)
|
|
676
|
+
// and then die with a raw ENOENT. Outside a worktree the missing directory
|
|
677
|
+
// is the only problem left, so it gets its own message.
|
|
678
|
+
const missingDir = path.join(T, "no-such-dir", "report.json");
|
|
679
|
+
r = cliRetry(["inventory", "--out", missingDir], PROJ);
|
|
680
|
+
ok(
|
|
681
|
+
"inventory explains a missing parent directory",
|
|
682
|
+
r.code !== 0 && /does not exist/.test(r.err) && !r.err.includes("ENOENT"),
|
|
683
|
+
(r.err || r.out).slice(0, 160),
|
|
684
|
+
);
|
|
685
|
+
// ...and inside a worktree the personal-content refusal wins over it.
|
|
686
|
+
const missingInTree = path.join(PROJ, "no-such-dir", "report.json");
|
|
687
|
+
r = cliRetry(["inventory", "--out", missingInTree], PROJ);
|
|
688
|
+
ok(
|
|
689
|
+
"worktree refusal wins over a missing directory",
|
|
690
|
+
r.code !== 0 && /git working tree/.test(r.err) && !fs.existsSync(missingInTree),
|
|
691
|
+
(r.err || r.out).slice(0, 160),
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// `open-memex list` is the surface a sceptical user runs first: it must
|
|
695
|
+
// not look complete when it isn't. Fixtures go straight into the outbox
|
|
696
|
+
// (appdata, never the repo), so this needs no commits and no cleanup in git.
|
|
697
|
+
const d70Key = (/project:\s*(\S+)/.exec(cli(["where"], PROJ).out) || [])[1] ?? "";
|
|
698
|
+
const outboxDir = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", d70Key);
|
|
699
|
+
const fillerIds: string[] = [];
|
|
700
|
+
for (let i = 0; i < 22; i++) {
|
|
701
|
+
const cid = `01P1D70${String(i).padStart(19, "0")}`;
|
|
702
|
+
fillerIds.push(cid);
|
|
703
|
+
fixture(outboxDir, cid, `fulltest d70 filler ${i} padding text`, d70Key);
|
|
704
|
+
}
|
|
705
|
+
cli(["reindex"], PROJ);
|
|
706
|
+
r = cliRetry(["list", "--limit", "5"], PROJ);
|
|
707
|
+
ok("cli list discloses truncation", /… and \d+ more not shown/.test(r.out), r.out.slice(-160));
|
|
708
|
+
ok(
|
|
709
|
+
"cli list renders the structured inventory line",
|
|
710
|
+
/^\d+\. \[[a-z]+\] id=\S+ created=\S+ source=/m.test(r.out),
|
|
711
|
+
r.out.slice(0, 200),
|
|
712
|
+
);
|
|
713
|
+
// Numbering runs across the whole listing, so a number names one entry.
|
|
714
|
+
r = cliRetry(["list", "--scope", "both", "--limit", "5"], PROJ);
|
|
715
|
+
const nums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
|
|
716
|
+
ok(
|
|
717
|
+
"numbers are unique and ascending across scopes",
|
|
718
|
+
nums.length > 2 && new Set(nums).size === nums.length && nums[0] === 1 && nums.every((n, i) => i === 0 || n === nums[i - 1]! + 1),
|
|
719
|
+
JSON.stringify(nums),
|
|
720
|
+
);
|
|
721
|
+
// The text report numbers the same way.
|
|
722
|
+
r = cliRetry(["inventory", "--scope", "project"], PROJ);
|
|
723
|
+
const textNums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
|
|
724
|
+
ok(
|
|
725
|
+
"inventory text numbers continuously too",
|
|
726
|
+
textNums.length > 2 && new Set(textNums).size === textNums.length && textNums.every((n, i) => i === 0 || n === textNums[i - 1]! + 1),
|
|
727
|
+
JSON.stringify(textNums.slice(0, 8)),
|
|
728
|
+
);
|
|
729
|
+
for (const id of fillerIds) fs.rmSync(path.join(outboxDir, `${id}.md`), { force: true });
|
|
730
|
+
cli(["reindex"], PROJ);
|
|
731
|
+
|
|
732
|
+
// A retraction of an in-repo memory is a local working-tree edit until it
|
|
733
|
+
// is committed - "hidden" must not read as "gone for the team". Runs in its
|
|
734
|
+
// own project so PROJ's git state stays clean for the sections above.
|
|
735
|
+
const d70Proj = mkproj("d70-proj");
|
|
736
|
+
const repoHideId = addMem(["fulltest d70 repo retraction"], d70Proj);
|
|
737
|
+
cliRetry(["submit", repoHideId], d70Proj);
|
|
738
|
+
r = cliRetry(["forget", repoHideId, "--soft"], d70Proj);
|
|
739
|
+
ok(
|
|
740
|
+
"soft hide of a repo memory warns about the commit",
|
|
741
|
+
/commit and push/i.test(r.out) && /team still sees it/i.test(r.out),
|
|
742
|
+
r.out.slice(0, 200),
|
|
743
|
+
);
|
|
744
|
+
r = cliRetry(["forget", repoHideId], d70Proj);
|
|
745
|
+
ok("hard forget of a hidden repo memory still deletes", r.code === 0 && /deleted/i.test(r.out), (r.err || r.out).slice(0, 120));
|
|
746
|
+
|
|
747
|
+
// An empty scope must still appear: "this project has nothing" is an answer.
|
|
748
|
+
const emptyProj = mkproj("empty-proj");
|
|
749
|
+
r = cliRetry(["inventory", "--scope", "project"], emptyProj);
|
|
750
|
+
ok(
|
|
751
|
+
"inventory shows an empty scope as 0",
|
|
752
|
+
/Project:.*\(0\)/.test(r.out) && /nothing remembered/i.test(r.out),
|
|
753
|
+
r.out.slice(0, 200),
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
// JSON: no absolute local paths, and the format id records the change.
|
|
757
|
+
r = cliRetry(["inventory", "--format", "json"], PROJ);
|
|
758
|
+
const invJson2 = JSON.parse(r.out.slice(r.out.indexOf("{")));
|
|
759
|
+
ok(
|
|
760
|
+
"inventory json is /2 and carries no absolute file paths",
|
|
761
|
+
invJson2.format === "open-memex-inventory/2" &&
|
|
762
|
+
!JSON.stringify(invJson2).includes(TESTENV.MY_O_MEMORY_HOME as string) &&
|
|
763
|
+
!/"file":/.test(JSON.stringify(invJson2)),
|
|
764
|
+
`${invJson2.format} ${JSON.stringify(invJson2).slice(0, 120)}`,
|
|
765
|
+
);
|
|
766
|
+
ok(
|
|
767
|
+
"inventory json counts hidden without the entries",
|
|
768
|
+
invJson2.hidden !== undefined && !("hiddenEntries" in invJson2),
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
615
772
|
console.log("\n================ SUMMARY ================");
|
|
616
773
|
console.log(`pass: ${pass}, fail: ${fail}`);
|
|
617
774
|
if (failures.length) { console.log("failed tests:"); for (const f of failures) console.log(" - " + f); }
|
|
@@ -25,6 +25,12 @@ open-memex add "I prefer concise diffs" --scope personal # applies everywhere
|
|
|
25
25
|
Keep each memory one self-contained statement. Scope routing: facts about the
|
|
26
26
|
user ("I"/"me") → `personal`; everything else → the current project.
|
|
27
27
|
|
|
28
|
+
One thing the user said is one memory. When their own wording triggered keyword
|
|
29
|
+
capture (`remember …`, `记住…`), that sentence is already stored — do not save it
|
|
30
|
+
again in any rewording. Save only what the stored text does not contain, as its
|
|
31
|
+
own statement; if your version replaces it, use `memory_supersede`. `memory_add`
|
|
32
|
+
refuses the echo and hands you the stored id.
|
|
33
|
+
|
|
28
34
|
Worth saving: decisions and their reasons, preferences, conventions, gotchas,
|
|
29
35
|
approaches tried and abandoned. Not worth saving: one-off task details or
|
|
30
36
|
anything re-derivable from the code.
|
|
@@ -78,7 +84,9 @@ plain words (who the words came from, when it was saved, project or
|
|
|
78
84
|
personal) instead of reading raw ids. If the user asks to remove one,
|
|
79
85
|
read the entry back in full and get a confirmation before deleting —
|
|
80
86
|
deletion is permanent, there is no undo. If they hesitate, offer hiding
|
|
81
|
-
instead (`--soft`): it
|
|
87
|
+
instead (`--soft`): it disappears from lists and search, but the file
|
|
88
|
+
survives. Either way, if the memory lives in the repo, the change is only
|
|
89
|
+
yours until you commit and push it.
|
|
82
90
|
|
|
83
91
|
```sh
|
|
84
92
|
open-memex forget <id> # delete (permanent)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn hand-off between keyword capture and the agent (D73).
|
|
3
|
+
*
|
|
4
|
+
* The keyword hook and the agent are two writers for one user turn. The hook
|
|
5
|
+
* stores the user's sentence verbatim, with no agent in the loop; the agent
|
|
6
|
+
* then sees only the chat transcript and cannot tell the sentence is already
|
|
7
|
+
* stored, so it saves its own paraphrase — the same statement lands two or
|
|
8
|
+
* three times (observed 2026-10-04: one `remember …` sentence → one
|
|
9
|
+
* `source: keyword` file plus two `source: tool` files).
|
|
10
|
+
*
|
|
11
|
+
* Two mechanisms, in this order:
|
|
12
|
+
* (a) the hand-off note — this module's `formatHandoffBlock`, pushed into the
|
|
13
|
+
* turn's system context so the agent knows what is already stored;
|
|
14
|
+
* (b) the turn-echo guard — `TURN_ECHO_THRESHOLD` below, enforced in
|
|
15
|
+
* `addMemory` (src/tools/ops.ts) over what the hook just wrote.
|
|
16
|
+
*
|
|
17
|
+
* Pure module: no db, no fs, no config, so the threshold behavior is testable
|
|
18
|
+
* in scripts/smoke-pure.ts.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Overlap bar for refusing a re-save of a just-captured user statement.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately high, and only ever applied to memories the *hook* wrote
|
|
25
|
+
* (`source: keyword` / `user`) inside `TURN_ECHO_WINDOW_MS`. Measured on the
|
|
26
|
+
* reported case and its near neighbours (token Jaccard, see
|
|
27
|
+
* `similarity` in src/store/lifecycle.ts): a wholesale re-save of the captured
|
|
28
|
+
* sentence scores 0.29–0.36, while a genuinely distinct fact the same
|
|
29
|
+
* sentence also contained scores 0.21 and below. Lexical overlap cannot
|
|
30
|
+
* separate an agent's gloss from a new fact — those two measured 0.211 and
|
|
31
|
+
* 0.214 — so the guard sits at the high-confidence end and everything under
|
|
32
|
+
* the bar is left to (a) and the agent's judgment. A refusal is always
|
|
33
|
+
* reported with the stored id, so the caller can supersede or rephrase.
|
|
34
|
+
*/
|
|
35
|
+
export const TURN_ECHO_THRESHOLD = 0.28;
|
|
36
|
+
|
|
37
|
+
/** How long a hook capture counts as "this turn" (10 minutes). */
|
|
38
|
+
export const TURN_ECHO_WINDOW_MS = 10 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
/** One hook capture, in memory only — never persisted (like `injectedSessions`). */
|
|
41
|
+
export interface CaptureRecord {
|
|
42
|
+
id: string;
|
|
43
|
+
scopeKey: string;
|
|
44
|
+
content: string;
|
|
45
|
+
at: number;
|
|
46
|
+
/** The note has already been pushed for this capture. */
|
|
47
|
+
delivered?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isTurnEcho(score: number): boolean {
|
|
51
|
+
return score >= TURN_ECHO_THRESHOLD;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Records still inside the window. */
|
|
55
|
+
export function freshCaptures(records: CaptureRecord[], now: number): CaptureRecord[] {
|
|
56
|
+
return records.filter((r) => now - r.at <= TURN_ECHO_WINDOW_MS);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function preview(content: string): string {
|
|
60
|
+
const one = content.replace(/\s+/g, " ").trim();
|
|
61
|
+
return one.length > 160 ? `${one.slice(0, 160)}…` : one;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The hand-off note (a). Empty string when there is nothing to hand off. */
|
|
65
|
+
export function formatHandoffBlock(records: CaptureRecord[]): string {
|
|
66
|
+
if (records.length === 0) return "";
|
|
67
|
+
const lines = records.map((r) => `- id=${r.id} (${r.scopeKey}) — "${preview(r.content)}"`);
|
|
68
|
+
return [
|
|
69
|
+
"[open-memex] The user's own wording in this turn was ALREADY stored verbatim, before you saw it. It is not unsaved.",
|
|
70
|
+
...lines,
|
|
71
|
+
"Do not call memory_add to save that text again in any rewording — one statement is already one memory, and a rewording only adds a second copy that retrieval will return alongside the first.",
|
|
72
|
+
"Save a memory only for information those lines do NOT contain, as its own self-contained statement. If your version is meant to replace one of them, call memory_supersede with that id instead of memory_add.",
|
|
73
|
+
].join("\n");
|
|
74
|
+
}
|