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 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′** | 作用域显式 | **会话内的每一项读取都绑定显式键集**,不使用通配。面板/导出枚举全部键,那是**明确的行政通道**(管理员视图),不是会话读取。作用域键由宿主 `session.header.cwd` 派生;该字段缺失时回落进程 cwd —— 这是已知风险,不是保证。 |
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 → 所有此类会话共享宿主进程目录;深目录超过 12 层 → 项目作用域塌成工作区 | **不会**。doctor 不检查作用域身份,无相关测试 |
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.10.0",
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",
@@ -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
- const scopeKeys = scoped ? resolved.readKeys : store.allScopeKeys();
120
- const scopeLabel = scoped ? resolved.label : `all ${scopeKeys.length} stored scope(s)`;
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 }).readKeys;
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.10.0';
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 dir = typeof cwd === 'string' && cwd.length > 0 ? canonicalDir(cwd) : canonicalDir(process.cwd());
88
- const projectRoot = findProjectRoot(dir);
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,
@@ -1048,8 +1048,29 @@ export class Store {
1048
1048
  */
1049
1049
  stats(options = {}) {
1050
1050
  const keys = options.scopeKeys ?? [];
1051
- const filtered = keys.length > 0;
1052
- const marks = filtered ? ` AND m.scope_key IN (${keys.map(() => '?').join(', ')})` : '';
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(
@@ -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,
@@ -36,7 +36,7 @@ export function openFacadeStore(options = {}) {
36
36
  memoryRoot: root,
37
37
  paths,
38
38
  config,
39
- pluginVersion: '2.10.0',
39
+ pluginVersion: '2.11.0',
40
40
  readOnly: options.readOnly === true,
41
41
  });
42
42
  return { store, paths };