dsh-loci 2.4.1 → 2.5.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/CHANGELOG.md +30 -0
- package/docs/ACCEPTANCE.md +7 -1
- package/docs/SECURITY.md +5 -2
- package/package.json +1 -1
- package/src/host/doctor.js +49 -7
- package/src/host/index.js +1 -1
- package/src/host/store/store.js +33 -1
- package/src/mcp/store-facade.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) from 1.0.0.
|
|
6
6
|
|
|
7
|
+
## [2.5.0] — 2026-11-15
|
|
8
|
+
|
|
9
|
+
The rest of the boundary audit: one real hole in the trust gate, one check that could
|
|
10
|
+
never fail, and a number that was being read as more than it is.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A real quote could certify an unrelated claim.** The gate asked only "did the user
|
|
15
|
+
say this"; it never asked "is the claim about what they said". Quoting any real
|
|
16
|
+
sentence — "好的,继续吧" — was enough to launder a fabricated fact into the prompt.
|
|
17
|
+
`evidenceSupports` was written for exactly this and had no caller. It is now part of
|
|
18
|
+
the gate, and its threshold is **measured** rather than inherited: on Chinese text a
|
|
19
|
+
legitimate paraphrase scores 0.083–0.364 and a laundered claim scores 0.000, so the
|
|
20
|
+
bar is 0.05. The old 0.15 would have rejected "用户偏好中文" from "以后都用中文回复我".
|
|
21
|
+
|
|
22
|
+
- **`loci doctor`'s host row could never warn.** It printed `[ok]` for any recorded
|
|
23
|
+
version, including one outside `engines.dsh`. It now compares against the declared
|
|
24
|
+
range and warns when outside, degrading to `ok` (not a failure) when the range cannot
|
|
25
|
+
be parsed.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **The acceptance numbers are qualified, not deleted.** Every measurement in
|
|
30
|
+
`docs/ACCEPTANCE.md` was taken on **v1.0.0**; the tree is 2.5.0. Worse, the
|
|
31
|
+
acceptance question contains the entry title verbatim (`acceptance-e2e.mjs:94`), the
|
|
32
|
+
corpus fixture is readable from the agent's workspace, and there is no no-memory arm —
|
|
33
|
+
so "49/50" measures lookup, not memory effect. The page now says so at the top and on
|
|
34
|
+
the row itself. A re-acceptance on 2.5.x with held-out facts and a control arm is an
|
|
35
|
+
open item, not something a doc edit can settle.
|
|
36
|
+
|
|
7
37
|
## [2.4.1] — 2026-11-15
|
|
8
38
|
|
|
9
39
|
Three fixes from an independent boundary audit. All three were verified by running the
|
package/docs/ACCEPTANCE.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# ACCEPTANCE — measured results (v1.0.0)
|
|
2
2
|
|
|
3
|
+
> **Version warning.** Everything below was measured on **v1.0.0**. The current tree is
|
|
4
|
+
> 2.4.x: schema v3, auto-verified capture, the pending-review notice, lesson fields, the
|
|
5
|
+
> review-kind filter and the two-part quote gate all landed after these runs. Treat this
|
|
6
|
+
> page as a record of what was true then, not as a property of what ships now. A
|
|
7
|
+
> re-acceptance is an open item.
|
|
8
|
+
|
|
3
9
|
Environment: Windows 11 · DSH 0.2.2-rc.1 · Node 24.13.0 · model `local-test/deepseek-flash`
|
|
4
10
|
(reasoning off, 100k context) · every run in a throwaway `DSH_HOME` under `%TEMP%`.
|
|
5
11
|
The plugin under test is the packed tarball, installed with
|
|
@@ -9,7 +15,7 @@ Verdict summary:
|
|
|
9
15
|
|
|
10
16
|
| # | Row | Result |
|
|
11
17
|
|---|---|---|
|
|
12
|
-
| 1 |
|
|
18
|
+
| 1 | Seeded-corpus keyword recall (**not** an evaluation of whether memory helps) | **PASS on its own terms** — single 49/50, multi 10/10, broad 50/50. Caveats that limit what this number means: the question contains the entry title verbatim (scripts/acceptance-e2e.mjs:94), the corpus fixture is readable from the agent's workspace, and there is no no-memory control arm. It shows the tools can find an entry when the question names it; it does not show that injection improved an answer |
|
|
13
19
|
| 2 | Injection cost | **PASS** — 0 B empty; 3,450–3,507 B steady; ~1.2k tokens measured |
|
|
14
20
|
| 3 | Concurrency and crash | **PASS** — 200/200 rows, 0 lock errors, kill -9 recoverable |
|
|
15
21
|
| 4 | Migration and rollback | **PASS** — rehearsal upgrade + snapshot restore, row-identical |
|
package/docs/SECURITY.md
CHANGED
|
@@ -26,8 +26,11 @@ A memory is injected only when `status = 'verified'`. Status is derived, not
|
|
|
26
26
|
declared:
|
|
27
27
|
|
|
28
28
|
- `trust_source = 'user'` (a human asserted it in the panel) → `verified`;
|
|
29
|
-
- a tool write with an `evidenceQuote` of ≥8 characters that is
|
|
30
|
-
|
|
29
|
+
- a tool write with an `evidenceQuote` of ≥8 characters that is findable in **the
|
|
30
|
+
user's own messages of the live session** *and* shares substance with the claim
|
|
31
|
+
(≥5% character-bigram overlap) → `verified`; a quote that is real but unrelated to
|
|
32
|
+
the claim stays a candidate, because quoting a real sentence is the cheapest way to
|
|
33
|
+
launder a fabricated fact;
|
|
31
34
|
- anything else → `candidate`;
|
|
32
35
|
- instruction-shaped text → `disputed`;
|
|
33
36
|
- imported or summarized text → `candidate`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-loci",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Layered long-term memory for DeepSeek Harness: the prompt sees only a stable index, full text is one lookup away. Bi-temporal facts, strict scope isolation, verified-only injection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/host/doctor.js
CHANGED
|
@@ -10,8 +10,37 @@ import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:f
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { findDuplicateGroups } from './store/duplicates.js';
|
|
12
12
|
import { detectInstructionLike, renderSafe } from './inject/sanitize.js';
|
|
13
|
+
import { compareVersions } from './update.js';
|
|
13
14
|
|
|
14
15
|
/** One check result. */
|
|
16
|
+
/**
|
|
17
|
+
* Decide whether a host version falls inside a declared range.
|
|
18
|
+
*
|
|
19
|
+
* Only the comparisons `engines.dsh` actually uses are supported — `>=X <Y` and
|
|
20
|
+
* `>=X <Y-0` — because a full semver implementation is a dependency this plugin does
|
|
21
|
+
* not otherwise need. Anything else returns `unknown` and the caller assumes fine: a
|
|
22
|
+
* doctor row must never fail on a range it cannot read.
|
|
23
|
+
*
|
|
24
|
+
* @param version - the host version string.
|
|
25
|
+
* @param range - `engines.dsh`, e.g. `>=0.2.2-rc.1 <0.3.0-0`.
|
|
26
|
+
* @returns `'inside' | 'outside' | 'unknown'`.
|
|
27
|
+
*/
|
|
28
|
+
function checkAgainstRange(version, range) {
|
|
29
|
+
if (typeof range !== 'string' || typeof version !== 'string') return 'unknown';
|
|
30
|
+
const lower = /(?:^|\s)(>=)\s*([0-9][^\s]*)/.exec(range);
|
|
31
|
+
const upperLt = /(?:^|\s)<\s*([0-9][^\s]*)/.exec(range);
|
|
32
|
+
const upperLe = /(?:^|\s)<=\s*([0-9][^\s]*)/.exec(range);
|
|
33
|
+
if (lower === null && upperLt === null && upperLe === null) return 'unknown';
|
|
34
|
+
const compare = (a, b) => compareVersions(a, b);
|
|
35
|
+
// `compareVersions(a, b) > 0` means b is newer than a.
|
|
36
|
+
// lower bound: outside when the version is below it.
|
|
37
|
+
if (lower !== null && compare(lower[2], version) < 0) return 'outside';
|
|
38
|
+
// upper `<`: outside when the version is at or above it.
|
|
39
|
+
if (upperLt !== null && compare(version, upperLt[1]) <= 0) return 'outside';
|
|
40
|
+
// upper `<=`: outside only when strictly above it.
|
|
41
|
+
if (upperLe !== null && compare(version, upperLe[1]) < 0) return 'outside';
|
|
42
|
+
return 'inside';
|
|
43
|
+
}
|
|
15
44
|
function check(name, status, detail) {
|
|
16
45
|
return { name, status, detail };
|
|
17
46
|
}
|
|
@@ -64,15 +93,28 @@ export function runDoctor(options) {
|
|
|
64
93
|
const lastMaintenance = Number(health.migration?.to === undefined ? 0 : 0);
|
|
65
94
|
checks.push(check('rows', 'ok', `${health.counts.memory} revisions · ${health.counts.memory_head} entries · ${health.counts.entity} entities`));
|
|
66
95
|
|
|
67
|
-
// Which Harness opened this store
|
|
68
|
-
//
|
|
96
|
+
// Which Harness opened this store, and whether that version is inside the range this
|
|
97
|
+
// build declares. The row used to print `[ok]` for any version at all, which made it
|
|
98
|
+
// a warning surface that could never warn — the exact shape of check that survives
|
|
99
|
+
// review because it looks like engineering.
|
|
69
100
|
try {
|
|
70
101
|
const hostVersion = store.hostVersion ?? null;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
102
|
+
if (hostVersion === null) {
|
|
103
|
+
checks.push(check('host', 'warn', `not recorded yet; this build declares engines.dsh ${engines?.dsh ?? 'unknown'}`));
|
|
104
|
+
} else {
|
|
105
|
+
const verdict = checkAgainstRange(hostVersion, engines?.dsh);
|
|
106
|
+
checks.push(
|
|
107
|
+
verdict === 'inside'
|
|
108
|
+
? check('host', 'ok', `last opened by DeepSeek Harness ${hostVersion} (inside ${engines?.dsh})`)
|
|
109
|
+
: verdict === 'outside'
|
|
110
|
+
? check(
|
|
111
|
+
'host',
|
|
112
|
+
'warn',
|
|
113
|
+
`last opened by DeepSeek Harness ${hostVersion}, outside the declared ${engines?.dsh}; memory may misbehave`,
|
|
114
|
+
)
|
|
115
|
+
: check('host', 'ok', `last opened by DeepSeek Harness ${hostVersion} (range not parsed; assumed fine)`),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
76
118
|
} catch (error) {
|
|
77
119
|
checks.push(check('host', 'warn', `could not read: ${String(error?.message ?? error)}`));
|
|
78
120
|
}
|
package/src/host/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import { registerCommands } from './commands.js';
|
|
|
23
23
|
import { runDoctor } from './doctor.js';
|
|
24
24
|
|
|
25
25
|
/** Version stamped into the database and diagnostics. */
|
|
26
|
-
export const VERSION = '2.
|
|
26
|
+
export const VERSION = '2.5.0';
|
|
27
27
|
|
|
28
28
|
/** Optional collaborators; without them the plugin still serves tools. */
|
|
29
29
|
export const inject = ['systemPrompt', 'tools'];
|
package/src/host/store/store.js
CHANGED
|
@@ -1555,6 +1555,18 @@ export class Store {
|
|
|
1555
1555
|
verified = false;
|
|
1556
1556
|
}
|
|
1557
1557
|
this.quoteChecked += 1;
|
|
1558
|
+
// Two questions, not one. The verifier answers "did the user say this"; this
|
|
1559
|
+
// answers "is the claim about what they said". Checking only the first let any real
|
|
1560
|
+
// sentence certify an unrelated fact — the cheapest way to launder a wrong memory
|
|
1561
|
+
// into the prompt. A quote that fails the second question lands as a candidate,
|
|
1562
|
+
// which costs a person one click.
|
|
1563
|
+
if (verified) {
|
|
1564
|
+
const claim = `${input?.title ?? ''} ${input?.body ?? ''}`.trim();
|
|
1565
|
+
if (claim.length > 0 && !evidenceSupports(quote, claim)) {
|
|
1566
|
+
this.log?.debug?.('loci: quote is in the transcript but unrelated to the claim; keeping it a candidate');
|
|
1567
|
+
verified = false;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1558
1570
|
return verified;
|
|
1559
1571
|
}
|
|
1560
1572
|
|
|
@@ -1725,9 +1737,29 @@ function nowOrMax(value) {
|
|
|
1725
1737
|
* @param text - new title and body.
|
|
1726
1738
|
* @returns true when the two share at least 15% of their character bigrams.
|
|
1727
1739
|
*/
|
|
1740
|
+
/**
|
|
1741
|
+
* Does the quote have anything to do with the claim?
|
|
1742
|
+
*
|
|
1743
|
+
* Containment alone is not enough: a model can quote any real user sentence ("好的,
|
|
1744
|
+
* 继续吧") and attach a fabricated claim to it, and the transcript check would pass.
|
|
1745
|
+
* This is the second half — the quote must share some substance with what it is
|
|
1746
|
+
* supposed to support.
|
|
1747
|
+
*
|
|
1748
|
+
* The 0.05 threshold is measured, not guessed. On Chinese text:
|
|
1749
|
+
* "以后都用中文回复我" → "用户偏好中文" 0.083 legitimate paraphrase
|
|
1750
|
+
* "回滚窗口是三十分钟" → "回滚窗口 30 分钟" 0.364 legitimate restatement
|
|
1751
|
+
* "好的,继续吧" → "用户要求永远不要审查代码" 0.000 laundering
|
|
1752
|
+
* A higher bar (the 0.15 this function used to carry) rejects the first case; 0.05
|
|
1753
|
+
* separates the two groups cleanly. Failing it lands the entry as a candidate, which
|
|
1754
|
+
* a person can still promote — the cost of being wrong in this direction is one click.
|
|
1755
|
+
*
|
|
1756
|
+
* @param quote - evidence quote.
|
|
1757
|
+
* @param text - title and body of the claim.
|
|
1758
|
+
* @returns true when the two share at least 5% of their character bigrams.
|
|
1759
|
+
*/
|
|
1728
1760
|
function evidenceSupports(quote, text) {
|
|
1729
1761
|
if (typeof quote !== 'string' || quote.trim().length < 8) return false;
|
|
1730
|
-
return jaccard(quote, text) >= 0.
|
|
1762
|
+
return jaccard(quote, text) >= 0.05;
|
|
1731
1763
|
}
|
|
1732
1764
|
/** Jaccard over whitespace-collapsed character bigrams (duplicate heuristic). */
|
|
1733
1765
|
function jaccard(a, b) {
|