dsh-loci 2.10.0 → 2.11.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 +59 -0
- package/docs/PRIOR-ART.md +2 -2
- package/package.json +1 -1
- package/src/host/api/routes.js +10 -3
- package/src/host/index.js +1 -1
- package/src/host/scope.js +16 -6
- package/src/host/store/store.js +26 -2
- package/src/host/tools/tools.js +7 -0
- package/src/mcp/store-facade.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,65 @@ 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.11.0] — 2026-11-15
|
|
8
|
+
|
|
9
|
+
One fix, made because it is the last thing that could put another project's facts into a
|
|
10
|
+
conversation.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A session with no `cwd` borrowed the Host process directory as its scope.**
|
|
15
|
+
`resolveScopes` fell back to `process.cwd()` whenever the host did not supply
|
|
16
|
+
`session.header.cwd`. Every such session and every panel request therefore shared one
|
|
17
|
+
workspace key — the directory the Host process was started in, which for a packaged
|
|
18
|
+
install is the install directory. Two consequences: memories written there belonged to no
|
|
19
|
+
project, and any two cwd-less sessions could read each other's entries.
|
|
20
|
+
|
|
21
|
+
A missing cwd now yields an explicit `workspace:unresolved` / `project:unresolved`. It
|
|
22
|
+
collides with no real workspace, appears in no other session's read set, and the panel
|
|
23
|
+
labels it "unresolved workspace" — visible rather than quiet.
|
|
24
|
+
|
|
25
|
+
The trade is deliberate: a cwd-less session loses access to workspace memories instead of
|
|
26
|
+
being handed someone else's. That is the direction the I4′ invariant points.
|
|
27
|
+
|
|
28
|
+
### Docs
|
|
29
|
+
|
|
30
|
+
- `PRIOR-ART.md` I4′ and its expiry table said the process-cwd fallback was "a known risk,
|
|
31
|
+
not a guarantee". It is now a stated behaviour with a test, and the remaining half of that
|
|
32
|
+
risk (project-scope collapse past 12 ancestor directories, no `doctor` check on scope
|
|
33
|
+
identity) is named as still open.
|
|
34
|
+
|
|
35
|
+
## [2.10.1] — 2026-11-15
|
|
36
|
+
|
|
37
|
+
Three more ways a scope mistake could have answered with the wrong data. Found by checking
|
|
38
|
+
the last unverified claims from the audit instead of trusting the previous pass.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **`/api/loci/preview?cwd=…` returned 400.** The route read `resolved.readKeys` and
|
|
43
|
+
`resolved.label`, neither of which `resolveScopes` returns, so any caller that supplied a
|
|
44
|
+
`cwd` or `session` got `Cannot read properties of undefined (reading 'length')`. The panel
|
|
45
|
+
never sends those parameters, which is why it survived: a latent failure on the one
|
|
46
|
+
endpoint that answers "what does *this* conversation get". `/api/loci/knowledge` had the
|
|
47
|
+
same defect.
|
|
48
|
+
|
|
49
|
+
- **An explicit empty scope set meant "the whole store".** `stats({ scopeKeys: [] })` and
|
|
50
|
+
`usageReport({ scopeKeys: [] })` dropped the filter, while `list([])` and
|
|
51
|
+
`countPending([])` returned nothing. That asymmetry turns a scope-resolution failure into
|
|
52
|
+
a confident answer about every project on the machine. Now: omitted means the whole
|
|
53
|
+
database (an admin question), `[]` means nothing.
|
|
54
|
+
|
|
55
|
+
- **`update`, `pin`, `unpin`, `restore` and `merge` ignored the read set.** Only `loci_get`
|
|
56
|
+
and `loci_forget` were guarded in 2.10.0. `update` moved a foreign entry into the calling
|
|
57
|
+
session's scope, `pin` changed how it sorts in another project's index, and `restore`
|
|
58
|
+
could un-delete an entry a different project had removed.
|
|
59
|
+
|
|
60
|
+
### Note
|
|
61
|
+
|
|
62
|
+
`test/unit/scope-semantics.test.mjs` pins all three. The empty-versus-absent distinction is
|
|
63
|
+
asserted in both directions, because the failure mode is a silent widening rather than a
|
|
64
|
+
crash.
|
|
65
|
+
|
|
7
66
|
## [2.10.0] — 2026-11-15
|
|
8
67
|
|
|
9
68
|
The last four findings from the audit. All four are the same shape: an operation that
|
package/docs/PRIOR-ART.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
| **I1** | 注入闸门 | 只有 `status='verified'` 且未过期、未删除的条目进入提示词。`status` 由 store 重新派生,模型无法通过工具断言它。verified 来自**面板/CLI 的人工操作**,或**用户自己消息里逐字存在且与主张有实质关联的引文**(§2.1 的两问闸门)。 |
|
|
19
19
|
| **I2′** | 前缀稳定 | **没有任何写入会重排或改写已有行**;重求值只发生在会话/日边界,绝不在步内。尾部(`as of` 日期、`+N more`、待审提示)允许随写入与 UTC 日期变化。 |
|
|
20
20
|
| **I3** | 默认零网络 | 默认配置下不发任何请求。三条可选出网路径:更新检查、HTTP 向量通道、会话结束的抽取(经宿主 `ctx.llm`,非本进程直连)。前两条默认关闭,第三条默认 `off`。 |
|
|
21
|
-
| **I4′** | 作用域显式 |
|
|
21
|
+
| **I4′** | 作用域显式 | **会话内的每一项读取都绑定显式键集**,不使用通配;包括两个按 id 寻址的工具(`loci_get`/`loci_forget`)和全部按 id 寻址的写操作。面板/导出枚举全部键,那是**明确的行政通道**(管理员视图),不是会话读取。作用域键由宿主 `session.header.cwd` 派生;**该字段缺失时返回显式的 `workspace:unresolved`,不再回落进程 cwd**。 |
|
|
22
22
|
|
|
23
23
|
> **旧版 I2 / I4 为什么被替换**:I2 原文"无写入时字节级不变"在 UTC 零点自破(尾部日期),且在 TTL 到期时于会话中途静默改变内容;I4 原文"读永不越界"与 `allScopeKeys()` 的通配枚举矛盾。把它们改宽不是妥协,是把判据改到能被代码验证的精度。
|
|
24
24
|
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
| I1 | 结构(数据层)/ 语义依赖 | 宿主持续发 `session/event` 让引文缓冲存在 | 缓冲缺失 → 闸门**更严**(fail-safe,全部变候选);引文门槛或 `autoVerifyCaptured` 默认值改动 → 闸门**更松** | **不会**。所有单测都桩掉验证器,真实函数只在手动 e2e 里跑过 |
|
|
32
32
|
| I2′ | 结构(排序)/ 经济性依赖 | provider 的 prefix cache 粒度 | provider 改为按段缓存或取消前缀缓存 → 经济理由消失(安全性理由仍在) | **不会**。仓库里没有任何 cache 命中量;`request_stat` 记的是字节数 |
|
|
33
33
|
| I3 | 结构(无直连)/ 计数依赖 | 三个默认布尔值 + 枚举完整性 | 新增经宿主服务的出网(如 `ctx.llm` 的另一处调用)→ grep 门看不见 | **部分**。直接 `fetch` 会挂 CI,宿主中介出网不会 |
|
|
34
|
-
| I4′ | 结构(查询)/ 派生依赖 | `session.header.cwd` 存在性 | 会话创建时不带 cwd →
|
|
34
|
+
| I4′ | 结构(查询)/ 派生依赖 | `session.header.cwd` 存在性 | 会话创建时不带 cwd → 2.11.0 起落到显式的 `workspace:unresolved`(不泄漏,但该会话看不到工作区记忆);深目录超过 12 层 → 项目作用域塌成工作区 | **部分**。无 cwd 已由测试固定并与面板标签可见;12 层上限与作用域身份仍无 doctor 检查 |
|
|
35
35
|
|
|
36
36
|
**这套机制本身是这次审计最有价值的产出**:判据要带失效条件,否则它只是当时当地的偏好,被后人当成真理。任何新增不变量必须同时写它的失效信号。
|
|
37
37
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-loci",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.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/api/routes.js
CHANGED
|
@@ -116,8 +116,15 @@ export function registerApiRoutes(ctx, deps) {
|
|
|
116
116
|
const sessionId = url.searchParams.get('session') ?? undefined;
|
|
117
117
|
const scoped = cwd !== undefined || sessionId !== undefined;
|
|
118
118
|
const resolved = scoped ? resolveScopes({ cwd, sessionId }) : undefined;
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
// `resolveScopes` returns `{session, workspace, project, global, labels, cwd,
|
|
120
|
+
// projectRoot}` — there is no `readKeys` and no `label`, so this route threw
|
|
121
|
+
// "Cannot read properties of undefined (reading 'length')" whenever a caller supplied
|
|
122
|
+
// `?cwd=`. The panel never does, which is why it survived: a latent 400 on the one
|
|
123
|
+
// endpoint that answers "what does this conversation get".
|
|
124
|
+
const scopeKeys = scoped ? readScopeKeys(resolved, { includeGlobal: config.includeGlobal !== false }) : store.allScopeKeys();
|
|
125
|
+
const scopeLabel = scoped
|
|
126
|
+
? [resolved.labels.workspace, resolved.labels.project].filter((value, index, all) => all.indexOf(value) === index).join(' / ')
|
|
127
|
+
: `all ${scopeKeys.length} stored scope(s)`;
|
|
121
128
|
return json({
|
|
122
129
|
...renderIndexBody({
|
|
123
130
|
store,
|
|
@@ -153,7 +160,7 @@ export function registerApiRoutes(ctx, deps) {
|
|
|
153
160
|
const scopeKeys =
|
|
154
161
|
cwd === undefined && sessionId === undefined
|
|
155
162
|
? store.allScopeKeys()
|
|
156
|
-
: resolveScopes({ cwd, sessionId }).
|
|
163
|
+
: readScopeKeys(resolveScopes({ cwd, sessionId }), { includeGlobal: config.includeGlobal !== false });
|
|
157
164
|
return json(buildKnowledge(store, { limit, scopeKeys }));
|
|
158
165
|
});
|
|
159
166
|
|
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.11.0';
|
|
27
27
|
|
|
28
28
|
/** Optional collaborators; without them the plugin still serves tools. */
|
|
29
29
|
export const inject = ['systemPrompt', 'tools'];
|
package/src/host/scope.js
CHANGED
|
@@ -80,21 +80,31 @@ function pathKey(prefix, path) {
|
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Build the scope identity of one session.
|
|
83
|
+
*
|
|
84
|
+
* When the host does not supply a cwd, the identity is **explicitly unresolved** rather
|
|
85
|
+
* than borrowed from `process.cwd()`. Borrowing it meant every cwd-less session and every
|
|
86
|
+
* panel request shared one scope keyed to wherever the Host process happened to be
|
|
87
|
+
* installed — a silent cross-project bucket, and the opposite of what the explicit-scope
|
|
88
|
+
* invariant promises. An unresolved key appears in no other session's read set, so the
|
|
89
|
+
* failure is visible (the panel labels it "unresolved workspace") instead of a quiet leak.
|
|
90
|
+
*
|
|
83
91
|
* @param options - `sessionId`, `cwd`.
|
|
84
92
|
* @returns `{session, workspace, project, global, labels, cwd, projectRoot}`.
|
|
85
93
|
*/
|
|
86
94
|
export function resolveScopes({ sessionId, cwd }) {
|
|
87
|
-
const
|
|
88
|
-
const
|
|
95
|
+
const requested = typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined;
|
|
96
|
+
const dir = requested === undefined ? undefined : canonicalDir(requested);
|
|
97
|
+
const projectRoot = dir === undefined ? undefined : findProjectRoot(dir);
|
|
98
|
+
const unresolved = dir === undefined;
|
|
89
99
|
return {
|
|
90
100
|
session: `session:${sessionId ?? 'unknown'}`,
|
|
91
|
-
workspace: pathKey('workspace', dir),
|
|
92
|
-
project: pathKey('project', projectRoot),
|
|
101
|
+
workspace: unresolved ? 'workspace:unresolved' : pathKey('workspace', dir),
|
|
102
|
+
project: unresolved ? 'project:unresolved' : pathKey('project', projectRoot),
|
|
93
103
|
global: 'global:',
|
|
94
104
|
labels: {
|
|
95
105
|
session: sessionId ? `session ${sessionId}` : 'session',
|
|
96
|
-
workspace: basename(dir) || dir,
|
|
97
|
-
project: basename(projectRoot) || projectRoot,
|
|
106
|
+
workspace: unresolved ? 'unresolved workspace' : basename(dir) || dir,
|
|
107
|
+
project: unresolved ? 'unresolved project' : basename(projectRoot) || projectRoot,
|
|
98
108
|
global: 'global',
|
|
99
109
|
},
|
|
100
110
|
cwd: dir,
|
package/src/host/store/store.js
CHANGED
|
@@ -1048,8 +1048,29 @@ export class Store {
|
|
|
1048
1048
|
*/
|
|
1049
1049
|
stats(options = {}) {
|
|
1050
1050
|
const keys = options.scopeKeys ?? [];
|
|
1051
|
-
|
|
1052
|
-
|
|
1051
|
+
// undefined means nobody asked for a scope (an admin whole-store view); [] means
|
|
1052
|
+
// someone asked for a scope and got no keys — which must answer *nothing*, not
|
|
1053
|
+
// *everything*. It used to drop the filter in both cases, so a scope-resolution
|
|
1054
|
+
// failure turned into a confident answer about every project on the machine.
|
|
1055
|
+
if (Array.isArray(options.scopeKeys) && options.scopeKeys.length === 0) {
|
|
1056
|
+
return {
|
|
1057
|
+
schemaVersion: this.schemaVersion(),
|
|
1058
|
+
indexRev: this.indexRev(),
|
|
1059
|
+
revisions: 0,
|
|
1060
|
+
heads: 0,
|
|
1061
|
+
byStatus: {},
|
|
1062
|
+
byKind: {},
|
|
1063
|
+
byScope: {},
|
|
1064
|
+
hostVersion: this.hostVersion ?? readMeta(this.db, META_KEYS.hostVersion) ?? null,
|
|
1065
|
+
pendingCandidates: 0,
|
|
1066
|
+
disputed: 0,
|
|
1067
|
+
lastInjection: null,
|
|
1068
|
+
dbBytes: existsSync(this.paths.db) ? statSync(this.paths.db).size : 0,
|
|
1069
|
+
vector: { ...this.vector, ...this.embedding.health() },
|
|
1070
|
+
memoryRoot: this.memoryRoot,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
const marks = keys.length > 0 ? ` AND m.scope_key IN (${keys.map(() => '?').join(', ')})` : '';
|
|
1053
1074
|
const byStatus = {};
|
|
1054
1075
|
for (const row of this.db
|
|
1055
1076
|
.prepare(
|
|
@@ -1200,6 +1221,9 @@ export class Store {
|
|
|
1200
1221
|
usageReport(options = {}) {
|
|
1201
1222
|
const keys = options.scopeKeys ?? [];
|
|
1202
1223
|
const limit = Math.max(1, Math.min(200, options.limit ?? 20));
|
|
1224
|
+
// Same rule as `stats`: an explicit empty list means no rows; an absent one means the
|
|
1225
|
+
// whole store, which is what the panel's admin report asks for.
|
|
1226
|
+
if (Array.isArray(options.scopeKeys) && options.scopeKeys.length === 0) return [];
|
|
1203
1227
|
const marks = keys.length > 0 ? ` AND m.scope_key IN (${keys.map(() => '?').join(', ')})` : '';
|
|
1204
1228
|
const rows = this.db
|
|
1205
1229
|
.prepare(
|
package/src/host/tools/tools.js
CHANGED
|
@@ -209,11 +209,18 @@ export function createTools(deps) {
|
|
|
209
209
|
const context = execContext(exec);
|
|
210
210
|
const op = oneOf(args, 'op', ['create', 'update', 'merge', 'pin', 'unpin', 'restore'], 'create');
|
|
211
211
|
const id = str(args, 'id', { fallback: undefined });
|
|
212
|
+
// Every id-addressed op has to respect the read set, not just the two literally named
|
|
213
|
+
// get/forget. `update` moved a foreign entry into this session's scope, `pin` changed
|
|
214
|
+
// how it sorts in another project's index, and `restore` could un-delete something a
|
|
215
|
+
// different project had removed.
|
|
216
|
+
const idScopeKeys = () => readScopeKeys(context.scopes, { includeGlobal: config('includeGlobal') });
|
|
217
|
+
if (id !== undefined && op !== 'create') assertInScope(store, id, idScopeKeys(), `loci_write ${op}`);
|
|
212
218
|
|
|
213
219
|
if (op === 'merge') {
|
|
214
220
|
if (id === undefined) throw new Error('id is required for merge (the entry to keep)');
|
|
215
221
|
const sourceIds = Array.isArray(args?.sourceIds) ? args.sourceIds.filter((value) => typeof value === 'string') : [];
|
|
216
222
|
if (sourceIds.length === 0) throw new Error('sourceIds is required for merge (at least one id to fold in)');
|
|
223
|
+
for (const sourceId of sourceIds) assertInScope(store, sourceId, idScopeKeys(), 'loci_write merge source');
|
|
217
224
|
const result = store.merge({
|
|
218
225
|
targetId: id,
|
|
219
226
|
sourceIds,
|
package/src/mcp/store-facade.js
CHANGED