dsh-loci 2.10.0 → 2.10.1

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,37 @@ 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.10.1] — 2026-11-15
8
+
9
+ Three more ways a scope mistake could have answered with the wrong data. Found by checking
10
+ the last unverified claims from the audit instead of trusting the previous pass.
11
+
12
+ ### Fixed
13
+
14
+ - **`/api/loci/preview?cwd=…` returned 400.** The route read `resolved.readKeys` and
15
+ `resolved.label`, neither of which `resolveScopes` returns, so any caller that supplied a
16
+ `cwd` or `session` got `Cannot read properties of undefined (reading 'length')`. The panel
17
+ never sends those parameters, which is why it survived: a latent failure on the one
18
+ endpoint that answers "what does *this* conversation get". `/api/loci/knowledge` had the
19
+ same defect.
20
+
21
+ - **An explicit empty scope set meant "the whole store".** `stats({ scopeKeys: [] })` and
22
+ `usageReport({ scopeKeys: [] })` dropped the filter, while `list([])` and
23
+ `countPending([])` returned nothing. That asymmetry turns a scope-resolution failure into
24
+ a confident answer about every project on the machine. Now: omitted means the whole
25
+ database (an admin question), `[]` means nothing.
26
+
27
+ - **`update`, `pin`, `unpin`, `restore` and `merge` ignored the read set.** Only `loci_get`
28
+ and `loci_forget` were guarded in 2.10.0. `update` moved a foreign entry into the calling
29
+ session's scope, `pin` changed how it sorts in another project's index, and `restore`
30
+ could un-delete an entry a different project had removed.
31
+
32
+ ### Note
33
+
34
+ `test/unit/scope-semantics.test.mjs` pins all three. The empty-versus-absent distinction is
35
+ asserted in both directions, because the failure mode is a silent widening rather than a
36
+ crash.
37
+
7
38
  ## [2.10.0] — 2026-11-15
8
39
 
9
40
  The last four findings from the audit. All four are the same shape: an operation that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loci",
3
- "version": "2.10.0",
3
+ "version": "2.10.1",
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.10.1';
27
27
 
28
28
  /** Optional collaborators; without them the plugin still serves tools. */
29
29
  export const inject = ['systemPrompt', 'tools'];
@@ -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.10.1',
40
40
  readOnly: options.readOnly === true,
41
41
  });
42
42
  return { store, paths };