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 +71 -0
- package/client.js +70 -6
- package/package.json +1 -1
- package/src/host/index.js +1 -1
- package/src/host/store/store.js +30 -6
- package/src/mcp/store-facade.js +1 -1
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:
|
|
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
|
|
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(
|
|
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.
|
|
1237
|
+
style: { opacity: active ? 1 : 0.72, display: 'block' },
|
|
1192
1238
|
},
|
|
1193
|
-
h('path', {
|
|
1194
|
-
|
|
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
|
|
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
|
|
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'];
|
package/src/host/store/store.js
CHANGED
|
@@ -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,
|
|
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${
|
|
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(
|
|
1109
|
-
|
|
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')
|