dsh-memu-memory 1.1.3 → 1.2.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/AGENTS.md +1 -1
- package/README.md +39 -6
- package/README.zh.md +39 -7
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +3 -3
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +2 -2
- package/docs/memory-semantics.md +12 -10
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +396 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +23 -6
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +335 -35
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +144 -0
- package/lib/curator.js +86 -31
- package/lib/cycle.js +40 -6
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +534 -88
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +430 -43
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +29 -12
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +113 -10
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +51 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +2 -1
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +11 -1
- package/scripts/check-all.mjs +10 -1
- package/scripts/replay.mjs +573 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +216 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +331 -0
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +489 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +360 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +94 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +122 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +591 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +212 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +790 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +165 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The sidebar painted one snapshot and never heard about a write again: a memory
|
|
2
|
+
// added in the browser, or by the model mid-turn, stayed invisible until the tab
|
|
3
|
+
// was remounted. The first fix was a monotonic revision the reader polls — but that
|
|
4
|
+
// counter only moves in the process that wrote, so a tool call served elsewhere left
|
|
5
|
+
// the panel stale anyway. These cases pin all three halves: the counter moves on
|
|
6
|
+
// every write this process makes, the on-disk stamp moves for writes it did not
|
|
7
|
+
// make, and the heartbeat stays a bare marker with no entry bodies in it.
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
|
|
14
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-sidebar-refresh-'))
|
|
15
|
+
process.env.DSH_MEMU_HOME = home
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
createLibrary, ensureSession, loadPrivateIndex, savePrivateIndex, saveGlobalIndex, saveState, memoryRevision,
|
|
19
|
+
memoryStamp, clearCaches, syncActivity,
|
|
20
|
+
} = await import('../lib/state.js')
|
|
21
|
+
const { privateRemember } = await import('../lib/memory.js')
|
|
22
|
+
const { dispatch } = await import('../lib/rpc.js')
|
|
23
|
+
|
|
24
|
+
test.after(() => {
|
|
25
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function fixture(name) {
|
|
29
|
+
const cwd = path.join(home, name)
|
|
30
|
+
const lib = createLibrary({ name, cwd })
|
|
31
|
+
const sessionId = name + '-session'
|
|
32
|
+
ensureSession(sessionId, { cwd, libraryId: lib.id })
|
|
33
|
+
return { cwd, lib, sessionId }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('the heartbeat is a bare marker and needs no session', async () => {
|
|
37
|
+
fixture('refresh-a')
|
|
38
|
+
// Every pane polls this marker — the sidebar, the settings page, and the
|
|
39
|
+
// standalone WebUI. Only the settings page and the WebUI have no conversation,
|
|
40
|
+
// so the marker itself must not demand one; the full first screen still does.
|
|
41
|
+
const beat = await dispatch({}, 'getSidebarState', { revisionOnly: true })
|
|
42
|
+
// Two fields, both tiny: the in-process counter and the on-disk fingerprint that
|
|
43
|
+
// a second window or a curator job can move.
|
|
44
|
+
assert.deepEqual(Object.keys(beat), ['revision', 'stamp'], 'the heartbeat must not carry entries')
|
|
45
|
+
assert.equal(typeof beat.revision, 'number')
|
|
46
|
+
assert.equal(typeof beat.stamp, 'string')
|
|
47
|
+
assert.equal(typeof syncActivity().lastBeatAt, 'number', 'a beat is recorded for memu_status')
|
|
48
|
+
await assert.rejects(
|
|
49
|
+
async () => { await dispatch({}, 'getSidebarState', {}) },
|
|
50
|
+
/requires a sessionId/,
|
|
51
|
+
'the full first screen still belongs to one conversation',
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('a write records an event memu_status can show', async () => {
|
|
56
|
+
const { lib, sessionId, cwd } = fixture('refresh-write')
|
|
57
|
+
const exec = { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } }
|
|
58
|
+
await privateRemember({ exec, content: '用户偏好周五下午整理下周计划,不要安排会议', title: '周五整理' })
|
|
59
|
+
const activity = syncActivity()
|
|
60
|
+
assert.equal(activity.lastWrite?.kind, 'private', 'the write event names what was written')
|
|
61
|
+
assert.equal(activity.lastWrite?.libraryId, lib.id, 'and which library it belongs to')
|
|
62
|
+
assert.equal(typeof activity.lastWrite?.at, 'number')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a private write moves the revision and lands in the next full read', async () => {
|
|
66
|
+
const { lib, sessionId, cwd } = fixture('refresh-b')
|
|
67
|
+
const before = (await dispatch({}, 'getSidebarState', { sessionId })).revision
|
|
68
|
+
const exec = { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } }
|
|
69
|
+
await privateRemember({ exec, content: '用户偏好周五下午整理下周计划,不要安排会议', title: '周五整理' })
|
|
70
|
+
assert.equal(loadPrivateIndex(lib.id).entries ? Object.keys(loadPrivateIndex(lib.id).entries).length : 0, 1)
|
|
71
|
+
const beat = await dispatch({}, 'getSidebarState', { sessionId, revisionOnly: true })
|
|
72
|
+
assert.ok(beat.revision > before, 'writing a memory must move the revision')
|
|
73
|
+
const full = await dispatch({}, 'getSidebarState', { sessionId })
|
|
74
|
+
assert.equal(full.revision, beat.revision, 'the full read must report the revision it painted')
|
|
75
|
+
assert.equal(full.private.items.length, 1, 'the new memory must be on the first screen')
|
|
76
|
+
assert.match(full.private.items[0].preview, /周五下午/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('archiving through the browser also moves it', async () => {
|
|
80
|
+
const { lib, sessionId } = fixture('refresh-c')
|
|
81
|
+
const index = loadPrivateIndex(lib.id)
|
|
82
|
+
index.entries['mem-refresh-1'] = { layer: 'long_term', title: 'old', content: 'body', status: 'active', createdAt: '2026-10-01T00:00:00.000Z' }
|
|
83
|
+
savePrivateIndex(lib.id, index)
|
|
84
|
+
const before = (await dispatch({}, 'getSidebarState', { sessionId })).revision
|
|
85
|
+
await dispatch({}, 'archiveMemory', { libraryId: lib.id, id: 'mem-refresh-1' })
|
|
86
|
+
assert.ok((await dispatch({}, 'getSidebarState', { sessionId, revisionOnly: true })).revision > before)
|
|
87
|
+
const full = await dispatch({}, 'getSidebarState', { sessionId })
|
|
88
|
+
assert.equal(full.private.items.length, 0, 'an archived entry must leave the list')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('the global index and state.json writes move it too', async () => {
|
|
92
|
+
fixture('refresh-d')
|
|
93
|
+
const a = memoryRevision()
|
|
94
|
+
saveGlobalIndex({ version: 1, entries: {} })
|
|
95
|
+
assert.ok(memoryRevision() > a, 'a global write must move the revision')
|
|
96
|
+
const b = memoryRevision()
|
|
97
|
+
saveState(Object.assign({}, JSON.parse(JSON.stringify({})), { sessions: {}, libraries: {}, settings: {} }))
|
|
98
|
+
assert.ok(memoryRevision() > b, 'a state write (folder policy, binding) must move the revision')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('the stamp hears a write this process never made', async () => {
|
|
102
|
+
const { lib, sessionId } = fixture('refresh-e')
|
|
103
|
+
const beat = await dispatch({}, 'getSidebarState', { sessionId, revisionOnly: true })
|
|
104
|
+
assert.equal(beat.stamp, memoryStamp(), 'the beat and a direct read agree on the stamp')
|
|
105
|
+
assert.equal(beat.stamp, memoryStamp(), 'reading the stamp changes nothing')
|
|
106
|
+
|
|
107
|
+
// Write the index straight to disk, the way another process or module instance
|
|
108
|
+
// would: no savePrivateIndex, so this process's counter never moves.
|
|
109
|
+
const index = loadPrivateIndex(lib.id)
|
|
110
|
+
index.entries['mem-outside-1'] = {
|
|
111
|
+
layer: 'long_term', title: 'outside', content: 'written elsewhere', status: 'active', createdAt: '2026-10-02T00:00:00.000Z',
|
|
112
|
+
}
|
|
113
|
+
fs.writeFileSync(path.join(home, 'index', lib.id + '.json'), JSON.stringify(index, null, 2))
|
|
114
|
+
|
|
115
|
+
const after = await dispatch({}, 'getSidebarState', { sessionId, revisionOnly: true })
|
|
116
|
+
assert.equal(after.revision, beat.revision, 'the per-process counter deliberately stands still')
|
|
117
|
+
assert.notEqual(after.stamp, beat.stamp, 'the stamp is the half another process can move')
|
|
118
|
+
|
|
119
|
+
clearCaches()
|
|
120
|
+
const full = await dispatch({}, 'getSidebarState', { sessionId })
|
|
121
|
+
assert.equal(full.stamp, after.stamp, 'the refetch reports the stamp it painted')
|
|
122
|
+
assert.equal(full.private.items.length, 1, 'the outside write shows up on the next full read')
|
|
123
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-sidebar-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const state = await import('../lib/state.js')
|
|
11
|
+
const { dispatch } = await import('../lib/rpc.js')
|
|
12
|
+
|
|
13
|
+
test.after(() => {
|
|
14
|
+
try { state.flushState() } catch {}
|
|
15
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('the sidebar first screen never lists conversations', async () => {
|
|
19
|
+
const cwd = path.join(home, 'proj')
|
|
20
|
+
const binding = state.ensureSession('sidebar-1', { cwd, agentPreset: 'coding' })
|
|
21
|
+
state.updatePrivateIndex(binding.libraryId, (index) => {
|
|
22
|
+
for (let i = 0; i < 40; i++) {
|
|
23
|
+
index.entries[`mem-${i}`] = {
|
|
24
|
+
id: `mem-${i}`, title: `记忆 ${i}`, content: 'x'.repeat(400),
|
|
25
|
+
layer: 'long_term', status: 'active', updatedAt: '2026-10-02T00:00:00.000Z',
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Listing sessions reads every session and its title snapshots — the thing that
|
|
31
|
+
// made opening the sidebar take seconds. It must not be called here at all.
|
|
32
|
+
let called = false
|
|
33
|
+
const ctx = {
|
|
34
|
+
get: (name) => (name === 'sessionQuery'
|
|
35
|
+
? { listSessions: async () => { called = true; throw new Error('must not be called') } }
|
|
36
|
+
: undefined),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const started = Date.now()
|
|
40
|
+
const view = await dispatch(ctx, 'getSidebarState', { sessionId: 'sidebar-1' })
|
|
41
|
+
const elapsed = Date.now() - started
|
|
42
|
+
|
|
43
|
+
assert.equal(called, false, 'getSidebarState must not list conversations')
|
|
44
|
+
assert.ok(elapsed < 500, `first screen took ${elapsed}ms`)
|
|
45
|
+
assert.equal(view.private.active, 40)
|
|
46
|
+
assert.equal(view.private.items.length, 40)
|
|
47
|
+
// Previews are truncated: shipping full bodies is what made the panel heavy.
|
|
48
|
+
assert.ok(view.private.items[0].preview.length <= 160)
|
|
49
|
+
assert.equal(view.sessions, undefined)
|
|
50
|
+
assert.equal(view.libraries, undefined)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a large library still returns a bounded first screen', async () => {
|
|
54
|
+
const cwd = path.join(home, 'proj-big')
|
|
55
|
+
const binding = state.ensureSession('sidebar-2', { cwd })
|
|
56
|
+
state.updatePrivateIndex(binding.libraryId, (index) => {
|
|
57
|
+
for (let i = 0; i < 500; i++) {
|
|
58
|
+
index.entries[`big-${i}`] = {
|
|
59
|
+
id: `big-${i}`, title: `big ${i}`, content: 'y'.repeat(300),
|
|
60
|
+
layer: 'long_term', status: 'active', updatedAt: '2026-10-02T00:00:00.000Z',
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
const started = Date.now()
|
|
65
|
+
const view = await dispatch({}, 'getSidebarState', { sessionId: 'sidebar-2', limit: 100 })
|
|
66
|
+
const elapsed = Date.now() - started
|
|
67
|
+
assert.ok(elapsed < 500, `500-entry library took ${elapsed}ms`)
|
|
68
|
+
assert.equal(view.private.active, 500)
|
|
69
|
+
assert.equal(view.private.items.length, 100, 'the payload is capped, the count is not')
|
|
70
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
const { queryTerms, firstHit, centeredSnippet } = await import('../lib/snippet.js')
|
|
5
|
+
|
|
6
|
+
test('query terms drop noise and keep the long phrase as a fallback', () => {
|
|
7
|
+
const terms = queryTerms('where a is the deploy key?')
|
|
8
|
+
assert.ok(terms.includes('deploy'))
|
|
9
|
+
// Single characters are noise; two-character words stay because they are real
|
|
10
|
+
// words in Chinese queries (需求, 计划).
|
|
11
|
+
assert.ok(!terms.includes('a'))
|
|
12
|
+
assert.ok(queryTerms('改 需求').includes('需求'))
|
|
13
|
+
assert.equal(terms[0], 'where a is the deploy key?', 'the whole query survives as the longest fallback term')
|
|
14
|
+
assert.deepEqual(queryTerms(' '), [])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('a hit is shown with room on both sides', () => {
|
|
18
|
+
const text = 'A'.repeat(200) + '不想再改需求' + 'B'.repeat(200)
|
|
19
|
+
const out = centeredSnippet(text, '不想再改需求')
|
|
20
|
+
assert.ok(out.includes('不想再改需求'), 'the hit itself is always inside the snippet')
|
|
21
|
+
assert.ok(out.length < 200, 'the two hundred As are not the interesting part')
|
|
22
|
+
assert.ok(out.startsWith('…') && out.endsWith('…'), 'both ends were cut')
|
|
23
|
+
// 40 before plus the hit plus 120 after, plus the two ellipses.
|
|
24
|
+
assert.ok(out.length >= 160 && out.length <= 165)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('a query with no hit falls back to the head instead of an empty string', () => {
|
|
28
|
+
const text = 'A'.repeat(300)
|
|
29
|
+
const out = centeredSnippet(text, 'mysql replication lag')
|
|
30
|
+
assert.equal(out, 'A'.repeat(160) + '…')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('a hit near the start does not open with an ellipsis', () => {
|
|
34
|
+
const out = centeredSnippet('deploy key is in the vault ' + 'B'.repeat(300), 'deploy')
|
|
35
|
+
assert.ok(out.startsWith('deploy key'), 'nothing was cut from the left, so nothing is claimed to be')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('short text is returned whole', () => {
|
|
39
|
+
assert.equal(centeredSnippet('the kettle boils slowly', 'kettle'), 'the kettle boils slowly')
|
|
40
|
+
assert.equal(centeredSnippet('', 'kettle'), '')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('firstHit is case-insensitive and picks the earliest term', () => {
|
|
44
|
+
const terms = queryTerms('Vault Ops')
|
|
45
|
+
assert.equal(firstHit('the vault was rotated', terms), 4)
|
|
46
|
+
assert.equal(firstHit('nothing here', terms), -1)
|
|
47
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Guards the message-source format that v4 of the host accepts. A v3 source
|
|
2
|
+
// (`{ kind: 'plugin', plugin: 'dsh-memu-memory' }`) is rejected at runtime with
|
|
3
|
+
// "format v4 message requires a producer-owned source kind" and the visible
|
|
4
|
+
// symptom is a conversation that will not open, so this must never reach a build.
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib')
|
|
12
|
+
const files = fs.readdirSync(root).filter((name) => name.endsWith('.js'))
|
|
13
|
+
|
|
14
|
+
// Mirrors the host's KNOWN_FORMS.
|
|
15
|
+
const KNOWN_FORMS = new Set(['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'])
|
|
16
|
+
|
|
17
|
+
test('no v3 plugin source kind survives anywhere in lib/', () => {
|
|
18
|
+
for (const name of files) {
|
|
19
|
+
const text = fs.readFileSync(path.join(root, name), 'utf8')
|
|
20
|
+
assert.doesNotMatch(text, /kind\s*:\s*['"]plugin['"]/, `${name} still builds a v3 source kind`)
|
|
21
|
+
assert.doesNotMatch(text, /kind\s*:\s*['"]plugin['"]\s*,\s*plugin\s*:/, `${name} still passes a bare plugin name`)
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('every emitted source kind is producer-owned', () => {
|
|
26
|
+
for (const name of files) {
|
|
27
|
+
const text = fs.readFileSync(path.join(root, name), 'utf8').replaceAll('plugin:${PLUGIN}', 'plugin:dsh-memu-memory')
|
|
28
|
+
for (const match of text.matchAll(/kind\s*:\s*[`'"]([^`'"]+)[`'"]/g)) {
|
|
29
|
+
const kind = match[1]
|
|
30
|
+
// Only source kinds are producer-owned; other `kind` fields (decisions,
|
|
31
|
+
// tool results) are unrelated and never contain a colon.
|
|
32
|
+
if (!kind.includes(':') && kind !== 'plugin') continue
|
|
33
|
+
assert.match(kind, /^plugin:[a-z0-9-]+$/, `${name} emits a source kind that is not producer-owned: ${kind}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('every injected message uses a form the host knows', () => {
|
|
39
|
+
for (const name of files) {
|
|
40
|
+
const text = fs.readFileSync(path.join(root, name), 'utf8')
|
|
41
|
+
for (const match of text.matchAll(/form\s*:\s*['"]([^'"]+)['"]/g)) {
|
|
42
|
+
assert.ok(KNOWN_FORMS.has(match[1]), `${name} uses unknown message form: ${match[1]}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('the plugin name is the one the source kind advertises', () => {
|
|
48
|
+
const inject = fs.readFileSync(path.join(root, 'inject.js'), 'utf8')
|
|
49
|
+
const privateHalf = fs.readFileSync(path.join(root, 'private.js'), 'utf8')
|
|
50
|
+
assert.match(inject, /const PLUGIN = 'dsh-memu-memory'/)
|
|
51
|
+
assert.match(privateHalf, /kind: 'plugin:dsh-memu-memory'/)
|
|
52
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-state-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const state = await import('../lib/state.js')
|
|
11
|
+
|
|
12
|
+
test.after(() => {
|
|
13
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('same cwd auto-binds to the same library', () => {
|
|
17
|
+
const cwd = path.join(home, 'project-a')
|
|
18
|
+
const a = state.ensureSession('state-a', { cwd, agentPreset: 'coding' })
|
|
19
|
+
const b = state.ensureSession('state-b', { cwd, agentPreset: 'coding' })
|
|
20
|
+
assert.equal(a.libraryId, b.libraryId)
|
|
21
|
+
assert.equal(a.domain, 'coding')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('different cwd gets a different library', () => {
|
|
25
|
+
const a = state.ensureSession('state-c', { cwd: path.join(home, 'project-b') })
|
|
26
|
+
const b = state.ensureSession('state-d', { cwd: path.join(home, 'project-c') })
|
|
27
|
+
assert.notEqual(a.libraryId, b.libraryId)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('a new memory binding defaults its domain selection to RP, and an explicit choice sticks', () => {
|
|
31
|
+
const cwd = path.join(home, 'project-domain-default')
|
|
32
|
+
const binding = state.ensureSession('state-domain-default', { cwd, agentPreset: 'coding' })
|
|
33
|
+
assert.equal(binding.domain, 'coding', 'the detected domain stays as metadata')
|
|
34
|
+
assert.equal(binding.domainOverride, 'rp', 'the selector starts on RP')
|
|
35
|
+
state.setSessionBinding('state-domain-default', { domainOverride: 'general' })
|
|
36
|
+
const rebound = state.ensureSession('state-domain-default', { cwd, agentPreset: 'coding' })
|
|
37
|
+
assert.equal(rebound.domainOverride, 'general', 'general is a real explicit choice')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('deleting a session binding keeps the library', () => {
|
|
41
|
+
const cwd = path.join(home, 'project-keep')
|
|
42
|
+
const binding = state.ensureSession('state-keep', { cwd })
|
|
43
|
+
assert.ok(state.getLibrary(binding.libraryId))
|
|
44
|
+
state.deleteSessionBinding('state-keep')
|
|
45
|
+
assert.equal(state.getSessionBinding('state-keep'), null)
|
|
46
|
+
assert.ok(state.getLibrary(binding.libraryId))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('the unpin marker is gone from session bindings', () => {
|
|
50
|
+
const cwd = path.join(home, 'project-trauma')
|
|
51
|
+
const binding = state.ensureSession('state-trauma', { cwd })
|
|
52
|
+
// Unpin is the model's own call now; nothing user-side arms it any more.
|
|
53
|
+
assert.equal(binding.traumaArmed, undefined)
|
|
54
|
+
assert.equal(binding.traumaArmedAt, undefined)
|
|
55
|
+
assert.equal(typeof state.armTrauma, 'undefined')
|
|
56
|
+
assert.equal(typeof state.consumeTrauma, 'undefined')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('backup creates a timestamped directory', () => {
|
|
60
|
+
const dest = state.backupDatabases()
|
|
61
|
+
assert.ok(fs.existsSync(dest))
|
|
62
|
+
assert.ok(fs.existsSync(path.join(dest, 'index')))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('memory ids are path-safe', () => {
|
|
66
|
+
const id = state.newMemoryId('mem')
|
|
67
|
+
assert.match(id, /^mem-[a-z0-9]+-[a-f0-9]+$/)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('the no-workspace placeholder stays a key, never a resolved path', () => {
|
|
71
|
+
assert.equal(state.normalizeCwd('__default__'), '__default__')
|
|
72
|
+
const lib = state.ensureLibraryForCwd('__default__')
|
|
73
|
+
assert.equal(lib.scopeKey, '__default__')
|
|
74
|
+
// Re-asking with nothing at all must land on the same library, not a second one.
|
|
75
|
+
assert.equal(state.ensureLibraryForCwd(undefined).id, lib.id)
|
|
76
|
+
assert.equal(state.createLibrary({ scopeKey: '__default__' }).id, lib.id)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('createLibrary:false binds the session without creating a library', () => {
|
|
80
|
+
const cwd = path.join(home, 'project-opt-out')
|
|
81
|
+
const before = state.listLibraries().length
|
|
82
|
+
const binding = state.ensureSession('state-opt-out', { cwd, createLibrary: false })
|
|
83
|
+
assert.equal(binding.libraryId, null)
|
|
84
|
+
assert.equal(binding.useLibrary, true)
|
|
85
|
+
assert.equal(state.listLibraries().length, before)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('a folder switch off suppresses auto-creation and is inherited by later sessions', () => {
|
|
89
|
+
const cwd = path.join(home, 'project-policy')
|
|
90
|
+
state.setWorkspacePolicy(cwd, { useLibrary: false, useGlobal: false })
|
|
91
|
+
const binding = state.ensureSession('state-policy', { cwd })
|
|
92
|
+
assert.equal(binding.libraryId, null)
|
|
93
|
+
assert.equal(state.effectiveScope(binding).useLibrary, false)
|
|
94
|
+
assert.equal(state.effectiveScope(binding).useGlobal, false)
|
|
95
|
+
const policy = state.listWorkspacePolicies().find((item) => item.scopeKey === binding.scopeKey)
|
|
96
|
+
assert.deepEqual({ useLibrary: policy.useLibrary, useGlobal: policy.useGlobal }, { useLibrary: false, useGlobal: false })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('a folder switch clamps conversations without rewriting their own choice', () => {
|
|
100
|
+
const cwd = path.join(home, 'project-policy-existing')
|
|
101
|
+
const binding = state.ensureSession('state-policy-existing', { cwd })
|
|
102
|
+
assert.ok(binding.libraryId)
|
|
103
|
+
assert.equal(binding.useLibrary, true)
|
|
104
|
+
|
|
105
|
+
state.setWorkspacePolicy(cwd, { useLibrary: false, useGlobal: false })
|
|
106
|
+
const after = state.getSessionBinding('state-policy-existing')
|
|
107
|
+
// The stored wish is untouched — the folder is a ceiling, not an edit.
|
|
108
|
+
assert.equal(after.useLibrary, true)
|
|
109
|
+
assert.equal(state.effectiveScope(after).useLibrary, false)
|
|
110
|
+
assert.equal(state.effectiveScope(after).useGlobal, false)
|
|
111
|
+
|
|
112
|
+
// Re-enabling the folder restores each conversation's own choice, not a default.
|
|
113
|
+
state.setSessionBinding('state-policy-existing', { useLibrary: false })
|
|
114
|
+
state.setWorkspacePolicy(cwd, { useLibrary: true, useGlobal: true })
|
|
115
|
+
const restored = state.getSessionBinding('state-policy-existing')
|
|
116
|
+
assert.equal(state.effectiveScope(restored).useLibrary, false, 'the conversation that opted out stays out')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('re-binding a session does not reset its switches', () => {
|
|
120
|
+
const cwd = path.join(home, 'project-sticky-switch')
|
|
121
|
+
state.ensureSession('state-sticky', { cwd })
|
|
122
|
+
state.setSessionBinding('state-sticky', { useGlobal: false })
|
|
123
|
+
// ensureSession runs on every step of every turn.
|
|
124
|
+
const rebound = state.ensureSession('state-sticky', { cwd, createLibrary: false })
|
|
125
|
+
assert.equal(rebound.useGlobal, false)
|
|
126
|
+
assert.equal(rebound.useLibrary, true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('an old Default/__default__ binding is renamed to the conversation folder', () => {
|
|
130
|
+
const cwd = path.join(home, 'project-adopted')
|
|
131
|
+
const legacy = state.ensureLibraryForCwd('__default__')
|
|
132
|
+
state.setSessionBinding('state-adopt', { libraryId: legacy.id, scopeKey: '__default__' })
|
|
133
|
+
const binding = state.ensureSession('state-adopt', { cwd })
|
|
134
|
+
assert.equal(binding.libraryId, legacy.id)
|
|
135
|
+
const library = state.getLibrary(legacy.id)
|
|
136
|
+
assert.equal(library.scopeKey, state.normalizeCwd(cwd))
|
|
137
|
+
assert.equal(library.name, 'project-adopted')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('a fresh install starts with the four conveniences enabled', () => {
|
|
141
|
+
const settings = state.getSettings()
|
|
142
|
+
assert.equal(settings.autoInstallEngine, true)
|
|
143
|
+
assert.equal(settings.newSessionPromptDismissed, false)
|
|
144
|
+
assert.equal(settings.habitsInjection, true)
|
|
145
|
+
assert.equal(settings.sleepConsolidation, true)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('vision descriptions are enabled by default and stay inert until an endpoint is configured', () => {
|
|
149
|
+
assert.equal(state.getSettings().visionCaptionEnabled, true)
|
|
150
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Task C: the statistics are a sidecar. These cases pin three things: the counters
|
|
2
|
+
// follow what actually happened (write / recall / inject / archive), the daily
|
|
3
|
+
// series is zero-filled and never renders NaN on an empty library, and a damaged
|
|
4
|
+
// stats file degrades the page without touching the memories.
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-stats-'))
|
|
12
|
+
process.env.DSH_MEMU_HOME = home
|
|
13
|
+
|
|
14
|
+
const state = await import('../lib/state.js')
|
|
15
|
+
const stats = await import('../lib/stats.js')
|
|
16
|
+
const memory = await import('../lib/memory.js')
|
|
17
|
+
const { dispatch } = await import('../lib/rpc.js')
|
|
18
|
+
|
|
19
|
+
test.after(() => {
|
|
20
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function fixture(name) {
|
|
24
|
+
const cwd = path.join(home, name)
|
|
25
|
+
const lib = state.createLibrary({ name, cwd })
|
|
26
|
+
state.ensureSession(`${name}-session`, { cwd, libraryId: lib.id })
|
|
27
|
+
const exec = { agent: { session: { header: { id: `${name}-session`, cwd, agentPreset: 'coding' } } } }
|
|
28
|
+
return { lib, cwd, exec }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('counters follow writes, recalls, injections and archives', async () => {
|
|
32
|
+
const { lib, exec } = fixture('stats-counts')
|
|
33
|
+
const written = await memory.privateRemember({
|
|
34
|
+
exec,
|
|
35
|
+
content: '周五下午不要安排会议,留给整理下周计划',
|
|
36
|
+
title: '周五整理',
|
|
37
|
+
source: 'user_said',
|
|
38
|
+
})
|
|
39
|
+
// A tool-driven recall is attention the user asked for; it counts as recalled.
|
|
40
|
+
await memory.privateRecall({ exec, query: '周五 会议 计划', relevanceThreshold: 0 })
|
|
41
|
+
// Being pushed into the prompt is the other half.
|
|
42
|
+
memory.noteInjected({ libraryId: lib.id, ids: [written.id] })
|
|
43
|
+
memory.privateArchive({ exec, id: written.id })
|
|
44
|
+
|
|
45
|
+
const view = await dispatch({}, 'getStats', { libraryId: lib.id, days: 30 })
|
|
46
|
+
assert.equal(view.totals.written, 1, 'one write')
|
|
47
|
+
assert.ok(view.totals.recalled >= 1, 'the recall surfaced the entry, got ' + view.totals.recalled)
|
|
48
|
+
assert.equal(view.totals.injected, 1, 'one injection of one entry')
|
|
49
|
+
assert.equal(view.totals.archived, 1, 'the archive button counts')
|
|
50
|
+
assert.equal(view.health.archived, 1, 'and the health block agrees')
|
|
51
|
+
assert.equal(view.health.active, 0, 'archived entries are not live')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('a library with no memories still renders zeros, never NaN', async () => {
|
|
55
|
+
const { lib } = fixture('stats-empty')
|
|
56
|
+
const view = await dispatch({}, 'getStats', { libraryId: lib.id, days: 30 })
|
|
57
|
+
assert.equal(view.available, true, 'no stats file yet is not an error')
|
|
58
|
+
assert.equal(view.days.length, 30, 'the series is still thirty days long')
|
|
59
|
+
for (const day of view.days) {
|
|
60
|
+
for (const key of ['written', 'injected', 'recalled', 'archived']) {
|
|
61
|
+
assert.equal(day[key], 0, `${day.date} ${key} is zero`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
assert.equal(view.health.total, 0)
|
|
65
|
+
assert.equal(view.health.avgStrength, 0)
|
|
66
|
+
assert.deepEqual(view.distribution, [])
|
|
67
|
+
assert.deepEqual(view.stale, [])
|
|
68
|
+
assert.deepEqual(view.ratio, { windowDays: 7, written: 0, injected: 0 })
|
|
69
|
+
assert.ok(!JSON.stringify(view).includes('NaN'), 'no NaN leaked into the payload')
|
|
70
|
+
assert.ok(!JSON.stringify(view).includes('Infinity'), 'Math.max(...[]) must not leak -Infinity')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('the daily series rolls at 90 days and the page only asks for 30', () => {
|
|
74
|
+
const now = new Date('2026-10-28T12:00:00')
|
|
75
|
+
for (let i = 0; i < 120; i += 1) {
|
|
76
|
+
stats.bumpStats('lib-roll', 'written', 1, now.getTime() - i * 86_400_000)
|
|
77
|
+
}
|
|
78
|
+
const loaded = stats.loadStats('lib-roll')
|
|
79
|
+
const keys = Object.keys(loaded.days).sort()
|
|
80
|
+
assert.equal(keys.length, stats.STATS_KEPT_DAYS, 'older days are trimmed on write')
|
|
81
|
+
assert.equal(keys[0], '2026-07-31', 'the window starts 89 days before the last write')
|
|
82
|
+
const view = stats.statsView({ libraryId: 'lib-roll', entries: {}, days: 30, now: now.getTime() })
|
|
83
|
+
assert.equal(view.days.length, 30, 'the page receives thirty days, not ninety')
|
|
84
|
+
assert.equal(view.days.at(-1).date, '2026-10-28')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('a damaged stats file degrades the page, not the memories', async () => {
|
|
88
|
+
const { lib, exec } = fixture('stats-broken')
|
|
89
|
+
fs.mkdirSync(path.join(home, 'index'), { recursive: true })
|
|
90
|
+
fs.writeFileSync(stats.statsPath(lib.id), '{"days": ', 'utf8')
|
|
91
|
+
|
|
92
|
+
const view = await dispatch({}, 'getStats', { libraryId: lib.id })
|
|
93
|
+
assert.equal(view.available, false, 'the page is told the numbers are missing')
|
|
94
|
+
assert.equal(view.days.length, 30, 'and still gets a drawable zero series')
|
|
95
|
+
|
|
96
|
+
// The memory path must not care: writing, listing and recalling still work.
|
|
97
|
+
const written = await memory.privateRemember({ exec, content: '统计坏了也要能写记忆', title: '仍然可写' })
|
|
98
|
+
assert.ok(written.id)
|
|
99
|
+
const list = await memory.privateList({ exec })
|
|
100
|
+
assert.equal(list.files.length, 1, 'the memory was written despite the broken sidecar')
|
|
101
|
+
await memory.privateRecall({ exec, query: '写记忆', relevanceThreshold: 0 })
|
|
102
|
+
assert.equal(fs.readFileSync(stats.statsPath(lib.id), 'utf8'), '{"days": ', 'a broken statistics sidecar is left intact')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('a local archive through an edit counts once', async () => {
|
|
106
|
+
const { lib, exec } = fixture('stats-edit-archive')
|
|
107
|
+
const first = await memory.privateRemember({ exec, content: '一条会被编辑归档的记忆', title: '编辑归档' })
|
|
108
|
+
await memory.privateUpdate({ exec, id: first.id, status: 'archived' })
|
|
109
|
+
await memory.privateUpdate({ exec, id: first.id, status: 'archived' })
|
|
110
|
+
const view = await dispatch({}, 'getStats', { libraryId: lib.id })
|
|
111
|
+
assert.equal(view.totals.archived, 1, 'the same transition does not count twice')
|
|
112
|
+
})
|