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
package/lib/graph.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { indexDir, readJsonStrict, writeJson, withFileLock, stamp } from './paths.js'
|
|
3
|
-
import { loadPrivateIndex, updatePrivateIndex, newMemoryId } from './state.js'
|
|
2
|
+
import { indexDir, readJsonStrict, writeJson, withFileLock, stamp, warn } from './paths.js'
|
|
3
|
+
import { loadPrivateIndex, updatePrivateIndex, newMemoryId, listLibraries } from './state.js'
|
|
4
4
|
import { resolveLibrary, sessionIdOf, visibleToSession, isMemoryEligible, decayStrength } from './memory.js'
|
|
5
5
|
|
|
6
6
|
export const LINK_KINDS = ['relation', 'commitment', 'event', 'person', 'conflict']
|
|
@@ -60,6 +60,16 @@ export function restoreGraphData(libraryId, data, memoryIdMap) {
|
|
|
60
60
|
if (!evidenceIds.length) continue
|
|
61
61
|
updateRelationship({ libraryId, fromId: entityMap[relationship.fromId], toId: entityMap[relationship.toId], state: relationship.state, evidenceIds, reason: relationship.reason || 'Imported relationship with explicit evidence.' })
|
|
62
62
|
}
|
|
63
|
+
// A backup carries the whole entity store, but only the memories that made it
|
|
64
|
+
// through the export/import mapping come back, so entities nothing points at
|
|
65
|
+
// any more would sit in the file forever as invisible weight. Collect them
|
|
66
|
+
// once the remap is done; a prune failure must not fail a restore that
|
|
67
|
+
// already succeeded, and the startup sweep would catch it on the next launch.
|
|
68
|
+
try {
|
|
69
|
+
pruneOrphanEntities(scope.libraryId)
|
|
70
|
+
} catch (error) {
|
|
71
|
+
warn(`orphan entity prune after graph restore skipped: ${error?.message || error}`)
|
|
72
|
+
}
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
export function createEntity({ exec, libraryId, name, kind = 'person', aliases = [] }) {
|
|
@@ -102,6 +112,119 @@ export function renameEntity({ libraryId, entityId, name, aliases = [] }) {
|
|
|
102
112
|
})
|
|
103
113
|
}
|
|
104
114
|
|
|
115
|
+
// What keeps an entity alive. Edges only live in one direction: an entry's
|
|
116
|
+
// links[] names the entity, the entity stores nothing back. On top of that,
|
|
117
|
+
// relationship snapshots (same file, keyed by the endpoint pair) describe the
|
|
118
|
+
// pair with history and evidence that outlive the individual entries — deleting
|
|
119
|
+
// an endpoint would leave a snapshot whose endpoints no longer resolve, so a
|
|
120
|
+
// snapshot counts as a reference too. An entity referenced by neither is an
|
|
121
|
+
// *orphan*: unreachable except as a graph placeholder, and safe to collect.
|
|
122
|
+
// Orphans come from old builds whose permanent delete dropped the memory row
|
|
123
|
+
// without cascading, and from partial backups.
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Remove every entity in one library that no entry links to and no relationship
|
|
127
|
+
* snapshot references. Idempotent: on healthy data it writes nothing. Returns
|
|
128
|
+
* `{ removed, total }` with the removed entity ids.
|
|
129
|
+
*/
|
|
130
|
+
export function pruneOrphanEntities(libraryId) {
|
|
131
|
+
const scope = scopeFor({ libraryId })
|
|
132
|
+
const file = entitiesPath(scope.libraryId)
|
|
133
|
+
return withFileLock(file, () => {
|
|
134
|
+
const store = readEntities(scope.libraryId)
|
|
135
|
+
const ids = Object.keys(store.entities)
|
|
136
|
+
if (!ids.length) return { removed: [], total: 0 }
|
|
137
|
+
const referenced = new Set()
|
|
138
|
+
for (const entry of Object.values(loadPrivateIndex(scope.libraryId).entries || {})) {
|
|
139
|
+
for (const link of entry.links || []) referenced.add(link.to)
|
|
140
|
+
}
|
|
141
|
+
for (const relationship of Object.values(store.relationships || {})) {
|
|
142
|
+
referenced.add(relationship.fromId)
|
|
143
|
+
referenced.add(relationship.toId)
|
|
144
|
+
}
|
|
145
|
+
const removed = ids.filter(id => !referenced.has(id))
|
|
146
|
+
// Write only when something actually goes, so the common case never touches
|
|
147
|
+
// the file (mtime-based caches, backups and concurrent readers all benefit).
|
|
148
|
+
if (removed.length) {
|
|
149
|
+
for (const id of removed) delete store.entities[id]
|
|
150
|
+
writeJson(file, store)
|
|
151
|
+
}
|
|
152
|
+
return { removed, total: ids.length }
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The startup sweep: run pruneOrphanEntities over every active library. One
|
|
158
|
+
* corrupt store must not stop the rest, so per-library failures are collected
|
|
159
|
+
* instead of thrown; the caller decides how to log them. Archived libraries are
|
|
160
|
+
* deliberately left alone (frozen user data — restoring one runs the prune via
|
|
161
|
+
* restoreGraphData anyway). Returns `{ scanned, removed, failed }`.
|
|
162
|
+
*/
|
|
163
|
+
export function pruneOrphanEntitiesAll() {
|
|
164
|
+
const libraries = listLibraries()
|
|
165
|
+
const result = { scanned: libraries.length, removed: 0, failed: [] }
|
|
166
|
+
for (const library of libraries) {
|
|
167
|
+
try {
|
|
168
|
+
result.removed += pruneOrphanEntities(library.id).removed.length
|
|
169
|
+
} catch (error) {
|
|
170
|
+
result.failed.push({ libraryId: library.id, error: String(error?.message || error) })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Delete an entity. This is graph bookkeeping, not a memory write: the entity
|
|
178
|
+
* row goes, no memory text is touched. An entity still referenced by links or
|
|
179
|
+
* relationship snapshots is refused by default — silently deleting it would
|
|
180
|
+
* turn every one of those edges into a `[deleted]` placeholder and rewrite what
|
|
181
|
+
* those memories are *about*. `force` deletes through that: inbound links are
|
|
182
|
+
* removed from every entry (not session-filtered — a link left behind in an
|
|
183
|
+
* entry this conversation cannot see would dangle exactly the way this guard
|
|
184
|
+
* exists to prevent, and the entry text itself is untouched) and relationship
|
|
185
|
+
* snapshots on the endpoints are dropped with their history, which is the loss
|
|
186
|
+
* `force` is asking for. Index links are cleaned before the entity row so a
|
|
187
|
+
* crash between the two writes leaves at worst a collectable orphan, never a
|
|
188
|
+
* link to an entity that is already gone.
|
|
189
|
+
*/
|
|
190
|
+
export function deleteEntity({ exec, libraryId, entityId, force = false }) {
|
|
191
|
+
const scope = scopeFor({ exec, libraryId })
|
|
192
|
+
if (!entityId || typeof entityId !== 'string') throw new Error('entityId is required')
|
|
193
|
+
const file = entitiesPath(scope.libraryId)
|
|
194
|
+
return withFileLock(file, () => {
|
|
195
|
+
const store = readEntities(scope.libraryId)
|
|
196
|
+
if (!store.entities[entityId]) throw new Error('unknown entity')
|
|
197
|
+
const index = loadPrivateIndex(scope.libraryId)
|
|
198
|
+
let inbound = 0
|
|
199
|
+
for (const entry of Object.values(index.entries || {})) {
|
|
200
|
+
for (const link of entry.links || []) if (link.to === entityId) inbound += 1
|
|
201
|
+
}
|
|
202
|
+
const relationshipKeys = Object.keys(store.relationships || {})
|
|
203
|
+
.filter(key => { const [from, to] = JSON.parse(key); return from === entityId || to === entityId })
|
|
204
|
+
if ((inbound || relationshipKeys.length) && !force) {
|
|
205
|
+
const parts = []
|
|
206
|
+
if (inbound) parts.push(`${inbound} link(s) point at it`)
|
|
207
|
+
if (relationshipKeys.length) parts.push(`${relationshipKeys.length} relationship snapshot(s) reference it`)
|
|
208
|
+
throw new Error(`entity is still referenced: ${parts.join(' and ')}; remove the links first or pass force to delete the entity with them`)
|
|
209
|
+
}
|
|
210
|
+
let removedLinks = 0
|
|
211
|
+
if (inbound) {
|
|
212
|
+
updatePrivateIndex(scope.libraryId, fresh => {
|
|
213
|
+
for (const entry of Object.values(fresh.entries)) {
|
|
214
|
+
if (!Array.isArray(entry.links) || !entry.links.some(link => link.to === entityId)) continue
|
|
215
|
+
const kept = entry.links.filter(link => link.to !== entityId)
|
|
216
|
+
removedLinks += entry.links.length - kept.length
|
|
217
|
+
entry.links = kept
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
for (const key of relationshipKeys) delete store.relationships[key]
|
|
222
|
+
delete store.entities[entityId]
|
|
223
|
+
writeJson(file, store)
|
|
224
|
+
return { entityId, deleted: true, removedLinks, removedRelationships: relationshipKeys.length }
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
105
228
|
export function linkEntries({ exec, libraryId, fromId, toId, kind, note, confidence = 0.7 }) {
|
|
106
229
|
const scope = scopeFor({ exec, libraryId })
|
|
107
230
|
if (!LINK_KINDS.includes(kind)) throw new Error(`link kind must be ${LINK_KINDS.join(', ')}`)
|
|
@@ -145,7 +268,7 @@ export function unlinkEntry({ libraryId, fromId, toId, kind }) {
|
|
|
145
268
|
return { removed }
|
|
146
269
|
}
|
|
147
270
|
|
|
148
|
-
export function getGraph({ exec, libraryId, focusId = null, depth = 1, limit = 200 }) {
|
|
271
|
+
export function getGraph({ exec, libraryId, focusId = null, depth = 1, limit = 200, includeOrphans = false }) {
|
|
149
272
|
const scope = scopeFor({ exec, libraryId })
|
|
150
273
|
const index = loadPrivateIndex(scope.libraryId)
|
|
151
274
|
const entities = readEntities(scope.libraryId).entities
|
|
@@ -181,9 +304,16 @@ export function getGraph({ exec, libraryId, focusId = null, depth = 1, limit = 2
|
|
|
181
304
|
frontier = next
|
|
182
305
|
}
|
|
183
306
|
} else {
|
|
307
|
+
// Orphan entities — no edge from any visible entry — are hidden by default.
|
|
308
|
+
// After the permanent-delete cascade and pruneOrphanEntities they should not
|
|
309
|
+
// exist on disk at all; a leftover from old data is invisible weight, and a
|
|
310
|
+
// session-scoped viewer already never saw them (the check below). Pass
|
|
311
|
+
// includeOrphans to see what an old entities.json still holds. An explicit
|
|
312
|
+
// focusId always wins: asking for a node by id is intent, not browsing.
|
|
184
313
|
for (const id of [...visible, ...Object.keys(entities), ...adjacency.keys()]) {
|
|
185
314
|
if (selected.size >= cap) break
|
|
186
315
|
if (sessionIdOf(exec) && !visible.has(id) && !adjacency.has(id)) continue
|
|
316
|
+
if (!includeOrphans && entities[id] && !adjacency.has(id)) continue
|
|
187
317
|
selected.add(id)
|
|
188
318
|
}
|
|
189
319
|
}
|
|
@@ -207,8 +337,12 @@ export function recallRelations(libraryId, index, id, sessionId) {
|
|
|
207
337
|
const to = from === id ? link.to : from
|
|
208
338
|
const target = index.entries[to]
|
|
209
339
|
if (target && (!isMemoryEligible(target, { automatic: true }) || !visibleToSession(target, sessionId))) continue
|
|
210
|
-
|
|
211
|
-
|
|
340
|
+
// A dangling edge (entry and entity both gone) used to be dropped here
|
|
341
|
+
// while getGraph rendered the same disk state as a [deleted] placeholder —
|
|
342
|
+
// the two views disagreed. Both now report the edge and mark it `missing`;
|
|
343
|
+
// the caller filters if it wants the old silence back.
|
|
344
|
+
const missing = !target && !entities[to]
|
|
345
|
+
out.push({ id: to, title: target?.title || entities[to]?.name || '[deleted]', kind: link.kind, note: String(link.note || '').slice(0, 80), confidence: link.confidence ?? 0.7, missing })
|
|
212
346
|
}
|
|
213
347
|
}
|
|
214
348
|
return out.sort((a, b) => b.confidence - a.confidence).slice(0, 2)
|
package/lib/index.js
CHANGED
|
@@ -5,7 +5,8 @@ import { registerRpc } from './rpc.js'
|
|
|
5
5
|
import { registerCycle } from './cycle.js'
|
|
6
6
|
import { syncPromptDefaults } from './prompts.js'
|
|
7
7
|
import { ensureEngine, engineStatus, ENGINE_VERSION } from './engine.js'
|
|
8
|
-
import { getSettings, getMemoryBackend, backupDatabases, flushState } from './state.js'
|
|
8
|
+
import { getSettings, getMemoryBackend, backupDatabases, flushState, listLibraries } from './state.js'
|
|
9
|
+
import { pruneOrphanEntitiesAll } from './graph.js'
|
|
9
10
|
import { closeEmbeddingWorkers } from './local-embedding-manager.js'
|
|
10
11
|
import { bootstrapLocalVector } from './local-vector-service.js'
|
|
11
12
|
import { vectorStatus } from './vector-profile.js'
|
|
@@ -62,6 +63,35 @@ function startEngineBootstrap(ctx) {
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
// 兜底扫描(老数据升级):旧版本的永久删除只删条目本身,别的条目 links 里指向它的
|
|
67
|
+
// 入边和 entities.json 里的实体节点会留下来,restoreGraphData 的部分恢复也会。这里在
|
|
68
|
+
// 启动后延迟跑一次全库 pruneOrphanEntitiesAll。选「每次启动延迟扫」而不是「首次
|
|
69
|
+
// getGraph + 持久化已清理标记」:扫描只是对小 JSON 文件的读 + 偶尔一次原子写,健康
|
|
70
|
+
// 数据零写入(no-op),幂等且能在任何一次删除路径将来回归时自愈;持久化标记省不下
|
|
71
|
+
// 多少成本,反而多一个「标记存在但数据又坏了」的状态要维护。延迟 4 秒(在 1.5s 引擎
|
|
72
|
+
// 引导与 2.5s 向量引导之后)让激活路径零阻塞;新安装没有库、没有实体,扫描直接 no-op。
|
|
73
|
+
// 单库失败只收集不抛出,整段失败只 warn —— 加载绝不能被一次清理绊倒。
|
|
74
|
+
function startOrphanEntitySweep(ctx) {
|
|
75
|
+
try {
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
try {
|
|
78
|
+
const result = pruneOrphanEntitiesAll()
|
|
79
|
+
for (const failure of result.failed) {
|
|
80
|
+
try { ctx.logger?.warn?.(`dsh-memu-memory: orphan entity sweep skipped ${failure.libraryId}: ${failure.error}`) } catch {}
|
|
81
|
+
}
|
|
82
|
+
if (result.removed > 0) {
|
|
83
|
+
try { ctx.logger?.info?.(`dsh-memu-memory: orphan entity sweep collected ${result.removed} node(s) across ${result.scanned} library(ies)`) } catch {}
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
try { ctx.logger?.warn?.(`dsh-memu-memory: orphan entity sweep failed: ${error?.message || error}`) } catch {}
|
|
87
|
+
}
|
|
88
|
+
}, 4000)
|
|
89
|
+
timer.unref?.()
|
|
90
|
+
} catch (error) {
|
|
91
|
+
try { ctx.logger?.warn?.(`dsh-memu-memory: orphan entity sweep skipped: ${error?.message || error}`) } catch {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
65
95
|
export function apply(ctx, config) {
|
|
66
96
|
// A conversation that names an agent preset composes its model-facing plugin set from
|
|
67
97
|
// that preset's cordis.yml alone, and that subtree never delivers `agent/pre-step` to
|
|
@@ -121,6 +151,8 @@ export function apply(ctx, config) {
|
|
|
121
151
|
registerRpc(ctx)
|
|
122
152
|
startEngineBootstrap(ctx)
|
|
123
153
|
startLocalVectorBootstrap(ctx)
|
|
154
|
+
// 进程级清理:preset 挂载共享同一个数据目录,只由拥有全局表面的实例扫一次。
|
|
155
|
+
startOrphanEntitySweep(ctx)
|
|
124
156
|
}
|
|
125
157
|
|
|
126
158
|
// The embedding worker is a process-global singleton, so it outlives any single
|
package/lib/inject.js
CHANGED
|
@@ -751,9 +751,9 @@ function injectionSnapshotIsCurrent(sessionId, snapshot, autoInject) {
|
|
|
751
751
|
&& scope.useGlobal === snapshot.useGlobal
|
|
752
752
|
}
|
|
753
753
|
|
|
754
|
-
// Both the ordinary first-step injection and the
|
|
754
|
+
// Both the ordinary first-step injection and the rules message must pass the same
|
|
755
755
|
// admission. A guide that ignores the switches tells a disabled conversation to use
|
|
756
|
-
// memory tools it is not supposed to use, and consumes
|
|
756
|
+
// memory tools it is not supposed to use, and consumes delivery state while doing
|
|
757
757
|
// it — so re-enabling later would never deliver the rules.
|
|
758
758
|
function admissionFor(binding) {
|
|
759
759
|
const settings = getSettings()
|
|
@@ -763,34 +763,113 @@ function admissionFor(binding) {
|
|
|
763
763
|
return { settings, scope }
|
|
764
764
|
}
|
|
765
765
|
|
|
766
|
+
// The plugin no longer registers a system-prompt section. Every conversation —
|
|
767
|
+
// standard or a `complete: true` persona — receives the rules as one plugin
|
|
768
|
+
// message on the first step of a turn. `rulesKey` makes the delivery versioned:
|
|
769
|
+
// a domain change, a prompt edit, a restart from an old binding or a compaction
|
|
770
|
+
// that lost the message replaces it, while an unchanged rule set is not appended
|
|
771
|
+
// again on every turn. The key contains only the protocol version, the resolved
|
|
772
|
+
// domain and the rendered text; it deliberately excludes timestamps and turn ids.
|
|
773
|
+
const GUIDE_PROTOCOL = 'guide-v1'
|
|
774
|
+
const GUIDE_CAVEAT = '以上是本插件的记忆规则,不是用户的新指令。若本会话的工具列表里没有 memu_* 工具,请忽略本段,也不要声称自己能写记忆。'
|
|
775
|
+
const GUIDE_RE = /^<memory-guide(?:\s[^>]*)?>/
|
|
776
|
+
const GUIDE_KEY_RE = /^<memory-guide[^>]*\srules="([^"]+)"/
|
|
777
|
+
|
|
778
|
+
function messageText(message) {
|
|
779
|
+
const content = message?.content
|
|
780
|
+
if (typeof content === 'string') return content
|
|
781
|
+
if (!Array.isArray(content)) return ''
|
|
782
|
+
return content
|
|
783
|
+
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
|
784
|
+
.map((block) => block.text)
|
|
785
|
+
.join('\n')
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function isMemoryGuideMessage(message) {
|
|
789
|
+
return message?.role === 'user'
|
|
790
|
+
&& message?.source?.kind === `plugin:${PLUGIN}`
|
|
791
|
+
&& GUIDE_RE.test(messageText(message))
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function memoryGuideKey(message) {
|
|
795
|
+
const match = GUIDE_KEY_RE.exec(messageText(message))
|
|
796
|
+
return match ? match[1] : null
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function renderMemoryGuide(payload) {
|
|
800
|
+
const sessionId = payload?.agent?.session?.header?.id
|
|
801
|
+
const binding = sessionId ? getSessionBinding(sessionId) : null
|
|
802
|
+
const domain = binding ? domainOf(binding) : 'general'
|
|
803
|
+
const settings = getSettings()
|
|
804
|
+
const rendered = loadPromptForDomain('system-guide', {
|
|
805
|
+
mustRememberCap: settings.mustRememberCap,
|
|
806
|
+
unpinBudget: settings.unpinBudgetPerSession ?? 2,
|
|
807
|
+
shortTermTtlHours: settings.shortTermTtlHours ?? 72,
|
|
808
|
+
anchorTokens: settings.anchorTokens,
|
|
809
|
+
turnTokens: settings.turnTokens,
|
|
810
|
+
}, domain).text
|
|
811
|
+
if (!rendered || !rendered.trim()) throw new Error('system-guide prompt is empty')
|
|
812
|
+
const key = crypto.createHash('sha256')
|
|
813
|
+
.update([GUIDE_PROTOCOL, domain, rendered, GUIDE_CAVEAT].join('\0'), 'utf8')
|
|
814
|
+
.digest('hex')
|
|
815
|
+
.slice(0, 16)
|
|
816
|
+
const text = [`<memory-guide rules="${key}">`, rendered.trim(), '</memory-guide>', GUIDE_CAVEAT].join('\n')
|
|
817
|
+
return { key, domain, text, tokens: estTokens(text) }
|
|
818
|
+
}
|
|
819
|
+
|
|
766
820
|
/**
|
|
767
|
-
*
|
|
821
|
+
* Add, replace or restore the single effective rules message for this step.
|
|
768
822
|
*
|
|
769
|
-
* A
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
823
|
+
* A rules message is turn-level guidance, so it is only considered on step 1.
|
|
824
|
+
* On later steps of a long tool turn nothing is appended; the next turn's first
|
|
825
|
+
* step is the next legal admission point. `guideLost` is set by compaction,
|
|
826
|
+
* because the summary drops the old message and the model must learn the rules
|
|
827
|
+
* again. An unchanged key with no guide in the current view is *not* treated as
|
|
828
|
+
* loss: some hosts hand the plugin only the current step's messages, and
|
|
829
|
+
* re-appending there would duplicate the message on every turn.
|
|
774
830
|
*/
|
|
775
|
-
function
|
|
831
|
+
function ensureUnifiedGuide(ctx, payload, decision) {
|
|
776
832
|
try {
|
|
777
833
|
const header = payload?.agent?.session?.header
|
|
778
834
|
const sessionId = header?.id
|
|
779
835
|
if (!sessionId || header.origin === 'subagent') return decision
|
|
780
836
|
if (typeof payload?.step === 'number' && payload.step !== 1) return decision
|
|
781
837
|
const binding = getSessionBinding(sessionId)
|
|
782
|
-
if (!binding || binding
|
|
783
|
-
if (!admissionFor(binding)) return decision
|
|
838
|
+
if (!binding || !admissionFor(binding)) return decision
|
|
784
839
|
if (!decision || !Array.isArray(decision.messages)) return decision
|
|
785
|
-
const
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
840
|
+
const guide = renderMemoryGuide(payload)
|
|
841
|
+
const guides = decision.messages.filter(isMemoryGuideMessage)
|
|
842
|
+
const valid = guides.filter((message) => memoryGuideKey(message) === guide.key)
|
|
843
|
+
if (binding.guideRulesKey === guide.key && binding.guideLost !== true) {
|
|
844
|
+
if (valid.length === 1 && guides.length === 1) return decision
|
|
845
|
+
if (valid.length >= 1) {
|
|
846
|
+
// Projection only: drop old and duplicated copies, keep the first one
|
|
847
|
+
// that already matches. No binding write, no new message.
|
|
848
|
+
const kept = valid[0]
|
|
849
|
+
return { ...decision, messages: decision.messages.filter((message) => !isMemoryGuideMessage(message) || message === kept) }
|
|
850
|
+
}
|
|
851
|
+
// An obsolete message with no current copy stays for replacement below;
|
|
852
|
+
// no guide at all means the view simply did not include history.
|
|
853
|
+
if (guides.length === 0) return decision
|
|
854
|
+
}
|
|
855
|
+
const next = decision.messages.filter((message) => !isMemoryGuideMessage(message))
|
|
856
|
+
next.push(createMemoryMessage(guide.text))
|
|
857
|
+
setSessionBinding(sessionId, {
|
|
858
|
+
guideRulesKey: guide.key,
|
|
859
|
+
guideMessageAt: new Date().toISOString(),
|
|
860
|
+
guideLost: false,
|
|
861
|
+
})
|
|
862
|
+
record({
|
|
863
|
+
kind: 'system',
|
|
864
|
+
scope: 'private',
|
|
865
|
+
sessionId,
|
|
866
|
+
libraryId: binding.libraryId || null,
|
|
867
|
+
ok: true,
|
|
868
|
+
message: '记忆规则消息已投放 · ' + guide.domain + ' · ' + guide.tokens + ' tokens'
|
|
869
|
+
+ (binding.guideRulesKey ? ' · 更新' : ' · 首次'),
|
|
870
|
+
detail: { rulesKey: guide.key, domain: guide.domain, tokens: guide.tokens, replaced: guides.length },
|
|
871
|
+
})
|
|
872
|
+
return { ...decision, messages: next }
|
|
794
873
|
} catch (error) {
|
|
795
874
|
try { ctx.logger?.warn?.(`dsh-memu-memory: guide message skipped: ${error?.message || error}`) } catch {}
|
|
796
875
|
return decision
|
|
@@ -1083,62 +1162,8 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
|
|
|
1083
1162
|
return { ...decision, messages: rewritten }
|
|
1084
1163
|
}
|
|
1085
1164
|
|
|
1086
|
-
|
|
1087
|
-
* Section text must never throw. A missing default prompt file or an unknown key
|
|
1088
|
-
* would otherwise break setPrompt assembly for *every* conversation, which takes
|
|
1089
|
-
* the whole harness down rather than just memory. Fall back variant -> base ->
|
|
1090
|
-
* one line, and log.
|
|
1091
|
-
*/
|
|
1092
|
-
function sectionText(ctx, key, context) {
|
|
1093
|
-
try {
|
|
1094
|
-
const sessionId = context?.agent?.session?.header?.id || context?.session?.header?.id || null
|
|
1095
|
-
const binding = sessionId ? getSessionBinding(sessionId) : null
|
|
1096
|
-
return loadPromptForDomain(key, {
|
|
1097
|
-
mustRememberCap: getSettings().mustRememberCap,
|
|
1098
|
-
unpinBudget: getSettings().unpinBudgetPerSession ?? 2,
|
|
1099
|
-
shortTermTtlHours: getSettings().shortTermTtlHours ?? 72,
|
|
1100
|
-
// the coding variants talk about the anchor budget in tokens, so the number
|
|
1101
|
-
// has to come from the live settings and not from a literal in the prompt
|
|
1102
|
-
anchorTokens: getSettings().anchorTokens,
|
|
1103
|
-
turnTokens: getSettings().turnTokens,
|
|
1104
|
-
}, binding ? domainOf(binding) : 'general').text
|
|
1105
|
-
} catch (error) {
|
|
1106
|
-
try { ctx.logger?.warn?.(`dsh-memu-memory: ${key} prompt unavailable: ${error?.message || error}`) } catch {}
|
|
1107
|
-
return '(记忆规则暂时不可用:插件内部错误,不要向用户提及这一段。)'
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
export function registerInjection(ctx, options = {}) {
|
|
1165
|
+
export function registerInjection(ctx) {
|
|
1112
1166
|
const disposers = []
|
|
1113
|
-
// `guide: false` is how a *preset* conversation gets the memory anchor without being
|
|
1114
|
-
// told about tools its preset never mounted — the guide text references memu_* tools,
|
|
1115
|
-
// and a preset that does not list this plugin has no such tools.
|
|
1116
|
-
const wantGuide = options.guide !== false
|
|
1117
|
-
// `guideMessage` exists for conversations whose preset persona uses `complete: true`:
|
|
1118
|
-
// that flag makes the persona the only system prompt section, so a section injected
|
|
1119
|
-
// here is discarded before the model ever sees it. A plugin message still arrives, so
|
|
1120
|
-
// the rules are delivered once per conversation as text instead of as a section.
|
|
1121
|
-
const guideInMessage = options.guideMessage === true
|
|
1122
|
-
if (wantGuide) {
|
|
1123
|
-
try {
|
|
1124
|
-
ctx.inject(['systemPrompt'], (sctx) => {
|
|
1125
|
-
sctx.effect(() => sctx.systemPrompt.section({
|
|
1126
|
-
name: 'dsh-memu-memory:guide',
|
|
1127
|
-
order: 320,
|
|
1128
|
-
interpolate: false,
|
|
1129
|
-
text: (context) => sectionText(sctx, 'system-guide', context),
|
|
1130
|
-
}), 'dsh-memu-memory: guide prompt')
|
|
1131
|
-
sctx.effect(() => sctx.systemPrompt.section({
|
|
1132
|
-
name: 'dsh-memu-memory:private-policy',
|
|
1133
|
-
order: 321,
|
|
1134
|
-
interpolate: false,
|
|
1135
|
-
text: (context) => sectionText(sctx, 'private-policy', context),
|
|
1136
|
-
}), 'dsh-memu-memory: private policy prompt')
|
|
1137
|
-
})
|
|
1138
|
-
} catch (e) {
|
|
1139
|
-
try { ctx.logger?.warn?.(`dsh-memu-memory: systemPrompt guide unavailable: ${e?.message || e}`) } catch {}
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
1167
|
|
|
1143
1168
|
const offPre = ctx.on('agent/pre-step', async (payload, next) => {
|
|
1144
1169
|
const decision = await next()
|
|
@@ -1149,7 +1174,7 @@ export function registerInjection(ctx, options = {}) {
|
|
|
1149
1174
|
ctx,
|
|
1150
1175
|
log: (message) => { try { ctx.logger?.warn?.(`dsh-memu-memory: ${message}`) } catch {} },
|
|
1151
1176
|
})
|
|
1152
|
-
return
|
|
1177
|
+
return ensureUnifiedGuide(ctx, payload, injected)
|
|
1153
1178
|
} catch (error) {
|
|
1154
1179
|
try { setRecallError(payload?.agent?.session?.header?.id, `injection failed: ${error?.message || error}`) } catch {}
|
|
1155
1180
|
try { ctx.logger?.warn?.(`dsh-memu-memory: pre-step injection failed (fail-open): ${error?.stack || error}`) } catch {}
|
|
@@ -1173,6 +1198,9 @@ export function registerInjection(ctx, options = {}) {
|
|
|
1173
1198
|
.slice(-REPLAY_MAX)
|
|
1174
1199
|
releaseInjected(sessionId)
|
|
1175
1200
|
markReinjectPending(sessionId)
|
|
1201
|
+
// The compaction summary drops the plugin's rules message. Flag it so the
|
|
1202
|
+
// next turn's first step replaces it even though guideRulesKey still matches.
|
|
1203
|
+
if (before) setSessionBinding(sessionId, { guideLost: true })
|
|
1176
1204
|
if (recent.length > 0) {
|
|
1177
1205
|
setSessionBinding(sessionId, { replayIds: recent, replayAt: new Date().toISOString() })
|
|
1178
1206
|
}
|
|
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'
|
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { assertProfileVerified } from './vector-profile.js'
|
|
5
5
|
import { manifestModelId } from './local-embedding-worker.js'
|
|
6
|
+
import { windowsSystemEnv } from './paths.js'
|
|
6
7
|
|
|
7
8
|
const entry = fileURLToPath(new URL('./local-embedding-entry.js', import.meta.url))
|
|
8
9
|
const failure = (code, message) => Object.assign(new Error(message), { code, fallback: 'lexical' })
|
|
@@ -21,16 +22,23 @@ const DEFAULT_WINDOW_MS = 60_000
|
|
|
21
22
|
const DENIED_ENV_NAMES = Object.freeze(['http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'node_options'])
|
|
22
23
|
const DENIED_ENV_PREFIXES = Object.freeze(['memu_', 'dsh_memu_embed_', 'openai_', 'azure_openai_', 'anthropic_', 'gemini_', 'google_', 'cohere_'])
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// The system-variable overlay goes in *first* so a real host value always wins, and
|
|
26
|
+
// only after it does the filtered copy of the host environment. The host can hand a
|
|
27
|
+
// tool-spawned process an empty environment (docs/env-and-commands.md §9.1), and a
|
|
28
|
+
// child that then resolves `%SystemDrive%\ProgramData\...` literally writes that
|
|
29
|
+
// junk into its own cwd — which is how this repo once grew a "%SystemDrive%" folder.
|
|
30
|
+
// The worker loads ONNX/DirectML and tokenizer native libraries from exactly that
|
|
31
|
+
// kind of path, so it is the site that can least afford to miss the overlay.
|
|
32
|
+
export function childEnvironment(extra, { platform = process.platform, env = process.env } = {}) {
|
|
33
|
+
const inherited = {}
|
|
34
|
+
for (const [name, value] of Object.entries(env || {})) {
|
|
27
35
|
if (typeof value !== 'string') continue
|
|
28
36
|
const lower = name.toLowerCase()
|
|
29
37
|
if (DENIED_ENV_NAMES.includes(lower) || lower.includes('proxy')) continue
|
|
30
38
|
if (DENIED_ENV_PREFIXES.some(prefix => lower.startsWith(prefix))) continue
|
|
31
|
-
|
|
39
|
+
inherited[name] = value
|
|
32
40
|
}
|
|
33
|
-
return { ...env, ...extra }
|
|
41
|
+
return { ...windowsSystemEnv(platform, env || {}), ...inherited, ...extra }
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
// One isolated native process per immutable profile. Tokens live only in memory and
|
|
@@ -125,8 +133,13 @@ export function createEmbeddingWorkerManager({ spawnImpl = spawn, executable = p
|
|
|
125
133
|
abort(code, message)
|
|
126
134
|
stopChild(record)
|
|
127
135
|
}
|
|
136
|
+
// This timer is the only thing that turns "never reported ready" into the typed
|
|
137
|
+
// lexical fallback, so nothing may depend on the event loop being held open for
|
|
138
|
+
// another reason: an unconditional unref let acquire() stay pending forever whenever
|
|
139
|
+
// the child contributed no libuv handle of its own (a spawn stand-in, N2). It is
|
|
140
|
+
// dereferenced below only once the spawn proves a real child holds the process open,
|
|
141
|
+
// which is the sole case where this timer would merely delay shutdown.
|
|
128
142
|
timer = setTimeout(() => fail('LOCAL_VECTOR_START_TIMEOUT', 'the local embedding worker did not become ready in time'), timeoutMs)
|
|
129
|
-
timer.unref?.()
|
|
130
143
|
let child = null
|
|
131
144
|
try {
|
|
132
145
|
child = spawnImpl(executable, [entry], {
|
|
@@ -150,6 +163,11 @@ export function createEmbeddingWorkerManager({ spawnImpl = spawn, executable = p
|
|
|
150
163
|
return
|
|
151
164
|
}
|
|
152
165
|
record.child = child
|
|
166
|
+
// A real child holds the process open by itself (stdio pipes and its own handle), so
|
|
167
|
+
// its boot timer need not; a stand-in without a pid holds nothing, and dereferencing
|
|
168
|
+
// the timer there would leave the timeout unable to fire. A spawn that fails reaches
|
|
169
|
+
// fail() through 'error'/'close' and clears the timer either way.
|
|
170
|
+
if (Number.isInteger(child?.pid) && child.pid > 0) timer.unref?.()
|
|
153
171
|
// A worker that died turns the next stdin write into EPIPE. An unhandled 'error'
|
|
154
172
|
// event on a child pipe is an uncaught exception in the host process, so every
|
|
155
173
|
// pipe gets a sink: semantic recall degrades, the host does not go down with it.
|