dsh-loci 3.0.1 → 3.2.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,77 @@ 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
+ ## [3.2.0] — 2026-11-15
8
+
9
+ The sidebar was sending people to an empty room. Found in the live store, not in a test.
10
+
11
+ ### Fixed
12
+
13
+ - **"N item(s) waiting for you in Review" counted rows Review hides.** The card read
14
+ `stats.pendingCandidates`, which counted every candidate and disputed row in scope; the
15
+ Review tab lists only the kinds that can reach the prompt (`rule`, `semantic`,
16
+ `procedural`). Episodic session summaries are candidates *by design* — they are history,
17
+ not claims — so on a normal day the entire count was composed of rows Review deliberately
18
+ excludes.
19
+
20
+ Live evidence, after one afternoon of use: **3 pending rows, all `episodic`**, and the
21
+ sidebar offering to review them somewhere it would not. The count is now `0` and Review
22
+ shows `0`.
23
+
24
+ `REVIEWABLE_KINDS` is one exported list. `countPending`, `stats.pendingCandidates`,
25
+ `stats.disputed` and the panel's request all use it, so the badge and the list cannot drift
26
+ apart again. `byStatus` stays complete — that is a data view, not a to-do.
27
+
28
+ - **`stats.pendingCandidates` ignored `valid_to`** while `countPending` honoured it, so a
29
+ candidate closed by maintenance stayed in the badge. Both now exclude expired rows.
30
+
31
+ ### Why this matters beyond the cosmetic
32
+
33
+ The card was telling the user to do something that could not be done, which reads as "the
34
+ tool is broken" rather than "there is nothing to do". A number with a door is a promise.
35
+
36
+ ### Not fixed, recorded
37
+
38
+ Episodic summaries still accumulate as candidates and are only removed by the 30-day
39
+ candidate TTL. They are visible in the Feed tab, which is where history belongs, but nothing
40
+ says so. That is part of R24 (the panel explains almost nothing about itself).
41
+
42
+ ## [3.1.0] — 2026-11-15
43
+
44
+ Four things from looking at the live sidebar and panel.
45
+
46
+ ### Changed
47
+
48
+ - **The sidebar icon now has the same weight as the menu it sits in.** Native
49
+ `sidebar.panellist` entries receive `{ size, active }`; this one used `size` correctly but
50
+ drew inside 4–20 of a 24 viewBox with a small off-centre dot, so beside the shipped icons
51
+ it read thinner and smaller. Redrawn as a symmetric **layers** glyph spanning 3–21 at
52
+ stroke 1.8 — the optical weight of its neighbours, and a shape that says layered memory.
53
+
54
+ - **The panel is a readable column, not edge-to-edge text.** The scroll container's
55
+ horizontal padding became `max(16px, calc((100% - 860px) / 2))`, so rows sit in a centred
56
+ measure on a wide window without touching any tab component.
57
+
58
+ - **"N item(s) waiting for you in Review" is now a way in.** It was a sentence telling a
59
+ person to go somewhere else and find the tab. It is a button: it opens the panel already
60
+ on Review. The card and the panel mount separately, so the request travels through module
61
+ scope and is consumed once.
62
+
63
+ - **The card says what it knows, not only how much.** Commit `4065c98` (2026-11-13) recorded
64
+ the brief for this surface: *"not a dashboard … read the card and think 'it got that, it
65
+ recorded that, we agreed on that'"*, with one short paragraph of what the agent
66
+ understands in this project and pending work as one quiet line, never a number. 3.0.0
67
+ replaced that paragraph with counters. The counters stay — they were asked for and they
68
+ are useful — and one line of content goes back under them: a `Latest` row showing the most
69
+ recently trusted entry, from `GET /api/loci/knowledge?limit=1`.
70
+
71
+ ### Not changed
72
+
73
+ - `docs/PRIOR-ART.md` still records that the card should be scoped to **this project's read
74
+ set** rather than every scope. It currently is not: `/stats` and `/knowledge` called
75
+ without parameters enumerate the whole store, and the sidebar card has no access to the
76
+ session's cwd. Scoping the card is a real open item, not something to assert.
77
+
7
78
  ## [3.0.1] — 2026-11-15
8
79
 
9
80
  The 3.0.0 card was correct markup with no stylesheet, and the screenshot showed it.
package/client.js CHANGED
@@ -27,6 +27,9 @@ window.__ModuleLoader__.load({
27
27
  const API = '/api/loci';
28
28
  const ENTRY_ID = 'dsh-loci';
29
29
  const PANEL_ID = 'loci';
30
+ // The card and the panel mount separately, so a request to open a specific tab has
31
+ // to travel through module scope. The panel consumes it once, then clears it.
32
+ let requestedTab;
30
33
 
31
34
  const EN = {
32
35
  'meta.title': 'Loci memory palace',
@@ -44,6 +47,7 @@ window.__ModuleLoader__.load({
44
47
  'card.opened': 'Opened',
45
48
  'card.dead': 'Dead',
46
49
  'card.pendingHint': '{n} item(s) waiting for you in Review.',
50
+ 'card.latest': 'Latest',
47
51
  'card.updated': 'updated',
48
52
  'card.pendingSentence': '{n} still need your review',
49
53
  'card.pinned': 'pinned',
@@ -194,6 +198,11 @@ window.__ModuleLoader__.load({
194
198
  .loci-card-foot-grow { flex: 1 1 auto; }
195
199
  .loci-card-empty { font-size: 9.5px; color: var(--dsw-alias-label-secondary); }
196
200
  .loci-card-hint { font-size: 9px; color: var(--dsw-alias-label-secondary); }
201
+ .loci-card-known { display: flex; gap: 5px; align-items: baseline; min-width: 0; }
202
+ .loci-card-known-tag { flex: 0 0 auto; font-size: 8px; letter-spacing: .05em; text-transform: uppercase; color: var(--dsw-alias-label-secondary); }
203
+ .loci-card-known-text { min-width: 0; font-size: 9.5px; color: var(--dsw-alias-label-primary); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
204
+ .loci-card-hint-action { align-self: flex-start; margin: 0; padding: 2px 6px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font: inherit; font-size: 9px; line-height: 1.2; cursor: pointer; text-align: left; }
205
+ .loci-card-hint-action:hover { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
197
206
  .loci-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-brand-primary); flex: 0 0 auto; }
198
207
 
199
208
  .loci-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
@@ -203,7 +212,7 @@ window.__ModuleLoader__.load({
203
212
  .loci-tab:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
204
213
  .loci-tab.is-active { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-weight: 600; }
205
214
  .loci-grow { flex: 1 1 auto; }
206
- .loci-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 12px 16px 24px; }
215
+ .loci-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 14px max(16px, calc((100% - 860px) / 2)) 28px; }
207
216
  .loci-btn { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 6px; padding: 3px 9px; font: inherit; cursor: pointer; }
208
217
  .loci-btn:hover { border-color: var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); }
209
218
  .loci-btn:disabled { opacity: .5; cursor: default; }
@@ -438,6 +447,7 @@ window.__ModuleLoader__.load({
438
447
  // read as bare text inside the sidebar rather than as a card — and the audit's own
439
448
  // "no dashboard" objection was about refusing to interrupt, not about hiding data.
440
449
  const stats = useJson('/stats', { intervalMs: 30_000 });
450
+ const knowledge = useJson('/knowledge?limit=1', { intervalMs: 30_000 });
441
451
  const config = useJson('/config');
442
452
  const update = useJson('/update');
443
453
  const [checking, setChecking] = useState(false);
@@ -468,6 +478,10 @@ window.__ModuleLoader__.load({
468
478
  const injected = usage.reduce((total, row) => total + Number(row.injections ?? 0), 0);
469
479
  const opened = usage.reduce((total, row) => total + Number(row.reads ?? 0), 0);
470
480
  const dead = usage.filter((row) => Number(row.injections ?? 0) > 0 && Number(row.reads ?? 0) === 0).length;
481
+ // What the agent currently understands, newest first. Numbers answer "how much";
482
+ // this answers "what".
483
+ const top = knowledge.data?.items?.[0];
484
+ const latest = typeof top?.title === 'string' && top.title.length > 0 ? top.title : null;
471
485
 
472
486
  if (!wide) {
473
487
  return h(
@@ -548,6 +562,14 @@ window.__ModuleLoader__.load({
548
562
  metric(t('card.pending'), pending, pending > 0 ? 'is-warn' : ''),
549
563
  metric(t('card.disputed'), disputed, disputed > 0 ? 'is-error' : ''),
550
564
  ),
565
+ data !== undefined && latest !== null
566
+ ? h(
567
+ 'div',
568
+ { className: 'loci-card-known' },
569
+ h('span', { className: 'loci-card-known-tag' }, t('card.latest')),
570
+ h('span', { className: 'loci-card-known-text' }, latest),
571
+ )
572
+ : null,
551
573
  data !== undefined && entries > 0 && budget > 0
552
574
  ? h(
553
575
  'div',
@@ -573,7 +595,21 @@ window.__ModuleLoader__.load({
573
595
  h('span', { className: 'loci-card-foot-grow' }),
574
596
  )
575
597
  : null,
576
- pending > 0 ? h('div', { className: 'loci-card-hint' }, t('card.pendingHint').replace('{n}', String(pending))) : null,
598
+ pending > 0
599
+ ? h(
600
+ 'button',
601
+ {
602
+ type: 'button',
603
+ className: 'loci-card-hint loci-card-hint-action',
604
+ onClick: (event) => {
605
+ event.stopPropagation();
606
+ requestedTab = 'review';
607
+ open?.();
608
+ },
609
+ },
610
+ t('card.pendingHint').replace('{n}', String(pending)),
611
+ )
612
+ : null,
577
613
  );
578
614
  }
579
615
  // ---------------------------------------------------------------- panel
@@ -919,6 +955,7 @@ window.__ModuleLoader__.load({
919
955
  // Only the kinds that could actually reach the prompt are reviewable. An
920
956
  // episodic session summary is history: it is never injected, so parking it
921
957
  // here forever is how the queue became a wall nobody reads.
958
+ // The same three kinds the store counts as pending, so the badge and the list agree.
922
959
  const reviewable = 'kinds=rule,semantic,procedural';
923
960
  const candidates = useJson(`/list?status=candidate&limit=50&${reviewable}`);
924
961
  const disputed = useJson(`/list?status=disputed&limit=50&${reviewable}`);
@@ -1136,7 +1173,12 @@ window.__ModuleLoader__.load({
1136
1173
  }
1137
1174
 
1138
1175
  function MemoryPanel({ t }) {
1139
- const [tab, setTab] = useState('feed');
1176
+ const [tab, setTab] = useState(() => {
1177
+ // "N item(s) waiting for you in Review" in the card is a way in, not a remark.
1178
+ const wanted = requestedTab;
1179
+ requestedTab = undefined;
1180
+ return wanted ?? 'feed';
1181
+ });
1140
1182
  const [selected, setSelected] = useState(undefined);
1141
1183
  const [nonce, setNonce] = useState(0);
1142
1184
  const tabs = useMemo(
@@ -1180,6 +1222,10 @@ window.__ModuleLoader__.load({
1180
1222
  }
1181
1223
 
1182
1224
  function PanelIcon({ size, active }) {
1225
+ // Three stacked sheets: the product is a layered index, and the glyph has to read at
1226
+ // the same optical weight as the shipped icons beside it. The previous drawing lived
1227
+ // in 4–20 of the viewBox with a small off-centre dot, which made it look thinner and
1228
+ // smaller than its neighbours in the sidebar.
1183
1229
  return h(
1184
1230
  'svg',
1185
1231
  {
@@ -1188,10 +1234,28 @@ window.__ModuleLoader__.load({
1188
1234
  viewBox: '0 0 24 24',
1189
1235
  fill: 'none',
1190
1236
  'aria-hidden': true,
1191
- style: { opacity: active ? 1 : 0.75, display: 'block' },
1237
+ style: { opacity: active ? 1 : 0.72, display: 'block' },
1192
1238
  },
1193
- h('path', { d: 'M4 6.5h16M4 12h16M4 17.5h10', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' }),
1194
- h('circle', { cx: 18.5, cy: 17.5, r: 2.2, stroke: 'currentColor', strokeWidth: 1.5 }),
1239
+ h('path', {
1240
+ d: 'M12 3 21 7.5 12 12 3 7.5 12 3Z',
1241
+ stroke: 'currentColor',
1242
+ strokeWidth: 1.8,
1243
+ strokeLinejoin: 'round',
1244
+ }),
1245
+ h('path', {
1246
+ d: 'M3.6 12.2 12 16.4l8.4-4.2',
1247
+ stroke: 'currentColor',
1248
+ strokeWidth: 1.8,
1249
+ strokeLinecap: 'round',
1250
+ strokeLinejoin: 'round',
1251
+ }),
1252
+ h('path', {
1253
+ d: 'M3.6 16.8 12 21l8.4-4.2',
1254
+ stroke: 'currentColor',
1255
+ strokeWidth: 1.8,
1256
+ strokeLinecap: 'round',
1257
+ strokeLinejoin: 'round',
1258
+ }),
1195
1259
  );
1196
1260
  }
1197
1261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loci",
3
- "version": "3.0.1",
3
+ "version": "3.2.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/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 = '3.0.1';
26
+ export const VERSION = '3.2.0';
27
27
 
28
28
  /** Optional collaborators; without them the plugin still serves tools. */
29
29
  export const inject = ['systemPrompt', 'tools'];
@@ -118,6 +118,13 @@ function tierOf(kind, pinned) {
118
118
  /** Statuses that may be injected or returned by default. */
119
119
  export const INJECTABLE_STATUSES = Object.freeze(['verified']);
120
120
 
121
+ /**
122
+ * The kinds a person can review. Everything else — episodic summaries, working notes — is
123
+ * either history or scratch, and Review hides it. One list, used by the counter and the UI,
124
+ * so the number and the list cannot drift apart again.
125
+ */
126
+ export const REVIEWABLE_KINDS = Object.freeze(['rule', 'semantic', 'procedural']);
127
+
121
128
  /** The phrase a caller must echo before the whole store is forgotten. */
122
129
  export const CONFIRM_ALL = 'forget everything';
123
130
 
@@ -877,18 +884,25 @@ export class Store {
877
884
  * @param scopeKeys - explicit scope keys (never a wildcard).
878
885
  * @returns the count of candidate and disputed entries.
879
886
  */
880
- countPending(scopeKeys, now = this.now()) {
887
+ countPending(scopeKeys, options = {}) {
881
888
  if (scopeKeys.length === 0) return 0;
889
+ const now = options.now ?? this.now();
890
+ // Only rows a person can actually act on. Episodic summaries are candidates by design —
891
+ // they are history, not claims, and Review hides them — so counting them made the
892
+ // sidebar announce work with nowhere to do it.
893
+ const kinds = Array.isArray(options.kinds) && options.kinds.length > 0 ? options.kinds : REVIEWABLE_KINDS;
882
894
  const marks = scopeKeys.map(() => '?').join(', ');
895
+ const kindMarks = kinds.map(() => '?').join(', ');
883
896
  return Number(
884
897
  this.db
885
898
  .prepare(
886
899
  `SELECT COUNT(*) AS n FROM memory m
887
900
  WHERE m.status IN ('candidate', 'disputed') AND m.deleted_at IS NULL
888
901
  AND (m.valid_to IS NULL OR m.valid_to > ?)
902
+ AND m.kind IN (${kindMarks})
889
903
  AND m.scope_key IN (${marks})`,
890
904
  )
891
- .get(now, ...scopeKeys)?.n ?? 0,
905
+ .get(now, ...kinds, ...scopeKeys)?.n ?? 0,
892
906
  );
893
907
  }
894
908
 
@@ -1095,18 +1109,28 @@ export class Store {
1095
1109
  .all(...keys)) {
1096
1110
  byScope[row.scope] = Number(row.n);
1097
1111
  }
1112
+ // "Pending" and "disputed" are questions for a person, so they count only the kinds a
1113
+ // person can act on. Episodic summaries are candidates by design and Review hides them;
1114
+ // counting them here made the sidebar announce work with nowhere to do it. The byStatus
1115
+ // view below stays complete, because that is data rather than a to-do.
1116
+ const kindMarks = REVIEWABLE_KINDS.map(() => '?').join(', ');
1098
1117
  const pending = Number(
1099
1118
  this.db
1100
1119
  .prepare(
1101
1120
  `SELECT COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord
1102
- WHERE h.status = 'candidate' AND m.deleted_at IS NULL${marks}`,
1121
+ WHERE h.status = 'candidate' AND m.deleted_at IS NULL AND m.kind IN (${kindMarks})
1122
+ AND (m.valid_to IS NULL OR m.valid_to > ?)${marks}`,
1103
1123
  )
1104
- .get(...keys)?.n ?? 0,
1124
+ .get(...REVIEWABLE_KINDS, this.now(), ...keys)?.n ?? 0,
1105
1125
  );
1106
1126
  const disputed = Number(
1107
1127
  this.db
1108
- .prepare(`SELECT COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE h.status = 'disputed'${marks}`)
1109
- .get(...keys)?.n ?? 0,
1128
+ .prepare(
1129
+ `SELECT COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord
1130
+ WHERE h.status = 'disputed' AND m.kind IN (${kindMarks})
1131
+ AND (m.valid_to IS NULL OR m.valid_to > ?)${marks}`,
1132
+ )
1133
+ .get(...REVIEWABLE_KINDS, this.now(), ...keys)?.n ?? 0,
1110
1134
  );
1111
1135
  const lastInjection = this.db
1112
1136
  .prepare('SELECT ts, session_id, scope_key, section_bytes, entries_listed, entries_hidden, source FROM request_stat ORDER BY id DESC LIMIT 1')
@@ -36,7 +36,7 @@ export function openFacadeStore(options = {}) {
36
36
  memoryRoot: root,
37
37
  paths,
38
38
  config,
39
- pluginVersion: '3.0.1',
39
+ pluginVersion: '3.2.0',
40
40
  readOnly: options.readOnly === true,
41
41
  });
42
42
  return { store, paths };