dsh-memu-memory 1.2.0 → 1.2.2

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.
Files changed (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +19 -8
  3. package/README.zh.md +12 -6
  4. package/docs/contributing.md +16 -3
  5. package/docs/env-and-commands.md +8 -0
  6. package/docs/host-api.md +3 -1
  7. package/docs/memory-semantics.md +21 -2
  8. package/docs/troubleshooting.md +42 -3
  9. package/lib/activity-log.js +22 -5
  10. package/lib/backup.js +24 -2
  11. package/lib/binding-diagnostics.js +3 -3
  12. package/lib/client.js +1610 -117
  13. package/lib/consolidation-worker.js +68 -17
  14. package/lib/curator-evidence.js +131 -0
  15. package/lib/curator-policy.js +81 -0
  16. package/lib/curator.js +1299 -83
  17. package/lib/cycle.js +3 -0
  18. package/lib/embedding-download.js +39 -4
  19. package/lib/engine.js +6 -3
  20. package/lib/global-graph.js +282 -0
  21. package/lib/graph.js +190 -14
  22. package/lib/inbound.js +19 -0
  23. package/lib/index.js +33 -1
  24. package/lib/inject.js +277 -17
  25. package/lib/lexicon.js +1115 -0
  26. package/lib/local-vector-service.js +8 -6
  27. package/lib/managed-runtime.js +33 -2
  28. package/lib/memory.js +585 -76
  29. package/lib/memu.js +2 -2
  30. package/lib/migrate.js +83 -2
  31. package/lib/private-links.js +587 -0
  32. package/lib/private.js +128 -7
  33. package/lib/prompts/global-curator.md +76 -19
  34. package/lib/prompts/private-consolidation.coding.md +4 -1
  35. package/lib/prompts/private-consolidation.md +3 -1
  36. package/lib/prompts/private-consolidation.mixed.md +3 -1
  37. package/lib/prompts/private-consolidation.rp.md +3 -1
  38. package/lib/prompts.js +1 -1
  39. package/lib/rpc.js +622 -13
  40. package/lib/state.js +4 -1
  41. package/lib/text-retrieval.js +661 -0
  42. package/lib/text-similarity.js +124 -0
  43. package/lib/tools.js +230 -16
  44. package/lib/vector-migration.js +28 -3
  45. package/lib/vector-profile.js +24 -0
  46. package/lib/webui/standalone.css +17 -0
  47. package/lib/webui/standalone.js +2 -0
  48. package/package.json +3 -2
  49. package/scripts/acceptance-local.mjs +8 -5
  50. package/scripts/curator-pair-calibrate.mjs +259 -0
  51. package/scripts/lexicon-calibrate.mjs +230 -0
  52. package/scripts/lexicon-dedupe.mjs +318 -0
  53. package/scripts/replay.mjs +573 -0
  54. package/tests/absorb-review.test.mjs +353 -0
  55. package/tests/appendix-b.test.mjs +6 -2
  56. package/tests/appendix-b2.test.mjs +2 -2
  57. package/tests/client-export.test.mjs +520 -0
  58. package/tests/client-render.test.mjs +19 -3
  59. package/tests/consolidation-credential.test.mjs +16 -5
  60. package/tests/consolidation-feed.test.mjs +96 -0
  61. package/tests/consolidation-fork.test.mjs +144 -10
  62. package/tests/consolidation-snapshot.test.mjs +82 -4
  63. package/tests/curator-absorb.test.mjs +443 -0
  64. package/tests/curator-calibrate.test.mjs +173 -0
  65. package/tests/curator-clusters.test.mjs +281 -0
  66. package/tests/curator-commit.test.mjs +324 -0
  67. package/tests/curator-evidence.test.mjs +102 -0
  68. package/tests/curator-link.test.mjs +316 -0
  69. package/tests/curator-pairing.test.mjs +252 -0
  70. package/tests/curator-probe.test.mjs +15 -6
  71. package/tests/curator-review-mode.test.mjs +314 -0
  72. package/tests/disclosure.test.mjs +2 -1
  73. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  74. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  75. package/tests/fixtures/lexicon-stub.mjs +284 -0
  76. package/tests/graph-cascade.test.mjs +223 -0
  77. package/tests/graph.test.mjs +113 -0
  78. package/tests/help-page.test.mjs +415 -0
  79. package/tests/hybrid-recall.test.mjs +32 -2
  80. package/tests/inbound.test.mjs +6 -3
  81. package/tests/inject-guide.test.mjs +28 -0
  82. package/tests/inject.test.mjs +157 -5
  83. package/tests/lexicon-calibrate.test.mjs +48 -0
  84. package/tests/lexicon-dedupe.test.mjs +171 -0
  85. package/tests/lexicon-frequency.test.mjs +179 -0
  86. package/tests/lexicon-inject.test.mjs +410 -0
  87. package/tests/lexicon-perf.test.mjs +436 -0
  88. package/tests/lexicon-ui.test.mjs +547 -0
  89. package/tests/lexicon.test.mjs +922 -0
  90. package/tests/managed-memory.test.mjs +55 -0
  91. package/tests/managed-runtime.test.mjs +32 -0
  92. package/tests/private-links.test.mjs +445 -0
  93. package/tests/prompts.test.mjs +35 -0
  94. package/tests/recall-dedup.test.mjs +446 -0
  95. package/tests/sidebar-beat.test.mjs +5 -1
  96. package/tests/tool-schema.test.mjs +1 -1
  97. package/tests/tools.test.mjs +112 -3
  98. package/tests/vector-migration.test.mjs +58 -1
  99. package/tests/webui-auth.test.mjs +348 -2
  100. package/tests/webui.test.mjs +191 -2
package/lib/cycle.js CHANGED
@@ -9,6 +9,7 @@ import { startGlobalCuration } from './curator.js'
9
9
  import { runVisibilityMigrationPass } from './migrate.js'
10
10
  import { record } from './activity-log.js'
11
11
  import { ACTIVE_MEMORY_POLICY, suppressionReason } from './active-memory.js'
12
+ import { clearSessionState } from './consolidation-ledger.js'
12
13
 
13
14
  const SWEEP_MS = 5 * 60 * 1000
14
15
  // Long enough that the host has restored its session list and the agent objects
@@ -139,6 +140,7 @@ export async function runSleepPass(ctx, { now = Date.now(), log = () => {}, limi
139
140
  ran += 1
140
141
  } else if (isGoneSessionError(result?.error)) {
141
142
  setSessionBinding(binding.sessionId, { sessionGone: true, sessionGoneAt: new Date(now).toISOString(), sleepRetryAt: null, sleepAttempts: 0, lastSleepError: String(result.error) })
143
+ try { clearSessionState(binding.sessionId) } catch {}
142
144
  log(`sleep consolidation dropped ${binding.sessionId}: the conversation no longer exists`)
143
145
  } else {
144
146
  const attempts = Number(binding.sleepAttempts || 0) + 1
@@ -148,6 +150,7 @@ export async function runSleepPass(ctx, { now = Date.now(), log = () => {}, limi
148
150
  } catch (error) {
149
151
  if (isGoneSessionError(error?.message)) {
150
152
  setSessionBinding(binding.sessionId, { sessionGone: true, sessionGoneAt: new Date(now).toISOString(), sleepRetryAt: null, sleepAttempts: 0, lastSleepError: String(error.message) })
153
+ try { clearSessionState(binding.sessionId) } catch {}
151
154
  continue
152
155
  }
153
156
  // A missing service or transient file failure can heal without a new turn.
@@ -44,6 +44,35 @@ function readJsonFile(file) {
44
44
  }
45
45
  }
46
46
 
47
+ // Installed records are local state, not a trusted manifest. Resolve every
48
+ // recorded file without following symlinks out of its model directory.
49
+ function modelFileTarget(dir, relative) {
50
+ const text = String(relative || '')
51
+ if (!text || text.includes('\\') || path.isAbsolute(text)) return null
52
+ const parts = text.split('/')
53
+ if (parts.some(part => !part || part === '.' || part === '..')) return null
54
+ const root = path.resolve(dir)
55
+ const target = path.resolve(root, ...parts)
56
+ if (target !== root && !target.startsWith(root + path.sep)) return null
57
+ try {
58
+ const rootReal = fs.realpathSync(root)
59
+ const targetReal = fs.realpathSync(target)
60
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + path.sep)) return null
61
+ } catch {
62
+ return null
63
+ }
64
+ return target
65
+ }
66
+
67
+ function isRegularNonSymlink(file) {
68
+ try {
69
+ const stat = fs.lstatSync(file)
70
+ return stat.isFile() && !stat.isSymbolicLink()
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
47
76
  function writeJsonAtomic(file, value) {
48
77
  fs.mkdirSync(path.dirname(file), { recursive: true })
49
78
  const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`
@@ -163,16 +192,21 @@ export function createEmbeddingDownloadManager(options = {}) {
163
192
 
164
193
  function readInstall(entry) {
165
194
  const dir = modelDir(entry.id)
195
+ try {
196
+ const dirStat = fs.lstatSync(dir)
197
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) return { ok: false, code: 'EMBEDDING_INSTALL_INCOMPLETE' }
198
+ } catch { return null }
166
199
  const record = readJsonFile(recordPath(entry.id))
167
200
  if (!record || typeof record !== 'object') return null
168
201
  if (record.id !== entry.id) return { ok: false, code: 'EMBEDDING_INSTALL_MISMATCH' }
169
202
  const files = Array.isArray(record.files) ? record.files : []
170
203
  if (!files.length) return { ok: false, code: 'EMBEDDING_INSTALL_INCOMPLETE' }
171
204
  for (const file of files) {
172
- const target = path.join(dir, ...String(file.path).split('/'))
205
+ const target = modelFileTarget(dir, file.path)
206
+ if (!target || !isRegularNonSymlink(target)) return { ok: false, code: 'EMBEDDING_INSTALL_INCOMPLETE' }
173
207
  let stat
174
- try { stat = fs.statSync(target) } catch { return { ok: false, code: 'EMBEDDING_INSTALL_INCOMPLETE' } }
175
- if (!stat.isFile() || stat.size !== file.bytes) return { ok: false, code: 'EMBEDDING_INSTALL_INCOMPLETE' }
208
+ try { stat = fs.lstatSync(target) } catch { return { ok: false, code: 'EMBEDDING_INSTALL_INCOMPLETE' } }
209
+ if (stat.size !== file.bytes) return { ok: false, code: 'EMBEDDING_INSTALL_INCOMPLETE' }
176
210
  }
177
211
  return { ok: true, record }
178
212
  }
@@ -180,7 +214,8 @@ export function createEmbeddingDownloadManager(options = {}) {
180
214
  function deepVerify(entry, record) {
181
215
  const dir = modelDir(entry.id)
182
216
  for (const file of record.files || []) {
183
- const target = path.join(dir, ...String(file.path).split('/'))
217
+ const target = modelFileTarget(dir, file.path)
218
+ if (!target || !isRegularNonSymlink(target)) return false
184
219
  let hash
185
220
  try { hash = crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex') } catch { return false }
186
221
  if (hash !== file.sha256) return false
package/lib/engine.js CHANGED
@@ -523,7 +523,7 @@ export async function switchEngineVersion(version, { log = null, beforeSwitch =
523
523
 
524
524
  if (!acquireLock()) return { ok: false, status: 'installing', error: 'another install is already running' }
525
525
  try {
526
- const verified = await verifyEngine(target.python, { log })
526
+ const verified = await verifyEngine(target.python, { log, expectedVersion: version })
527
527
  // Record what the probe saw, so the version list can say "usable" or "broken"
528
528
  // instead of guessing from the presence of a venv directory.
529
529
  recordProbe(version, verified)
@@ -773,7 +773,7 @@ async function parseMemuCommands(python) {
773
773
  return { ok: true, commands: [...commands], help: text }
774
774
  }
775
775
 
776
- export async function verifyEngine(python, { log } = {}) {
776
+ export async function verifyEngine(python, { log, expectedVersion = null } = {}) {
777
777
  const versionRes = await run(python, ['-c', 'import importlib.metadata as m; print(m.version("memu-cli"))'], {
778
778
  timeoutMs: 30_000,
779
779
  })
@@ -781,6 +781,9 @@ export async function verifyEngine(python, { log } = {}) {
781
781
  return { ok: false, error: `memu-cli import failed: ${(versionRes.stderr || versionRes.stdout).trim()}` }
782
782
  }
783
783
  const version = versionRes.stdout.trim().split(/\r?\n/).pop() || ''
784
+ if (expectedVersion && normalizeVersion(version) !== normalizeVersion(expectedVersion)) {
785
+ return { ok: false, version, error: 'memu-cli version mismatch: expected ' + expectedVersion + ', got ' + version }
786
+ }
784
787
  const commands = await parseMemuCommands(python)
785
788
  if (!commands.ok) return { ok: false, version, error: commands.error }
786
789
  const missing = REQUIRED_COMMANDS.filter((c) => !commands.commands.includes(c))
@@ -905,7 +908,7 @@ export async function installEngine({ force = false, log = null, beforeSwitch =
905
908
 
906
909
  stateInstalling(`verifying:${base.source}`)
907
910
  const python = venvPython(tmpVenv)
908
- const verified = await verifyEngine(python, { log })
911
+ const verified = await verifyEngine(python, { log, expectedVersion: ENGINE_VERSION })
909
912
  if (!verified.ok) throw new Error(`verify failed: ${verified.error}`)
910
913
 
911
914
  if (typeof beforeSwitch === 'function') {
@@ -0,0 +1,282 @@
1
+ // Global memory links: validation, transactional application, removal.
2
+ //
3
+ // Why this exists (spec §6.6): graph.js linkEntries is private-library-only —
4
+ // it resolves its store through scopeFor({exec, libraryId}) and the global
5
+ // index lives in a separate file with no links layer. Global edges therefore
6
+ // live INSIDE entries[id].links[] (§3.2): the top level of the global index is
7
+ // dropped by loadGlobalIndex, so entry-embedded is the only safe place.
8
+ //
9
+ // Rules (§4.9 / §0.3 C9/C10):
10
+ // * kinds: related (symmetric, both ends share id+opId) / refines / supports
11
+ // (directed, source end only, DFS cycle check depth ≤3).
12
+ // * note 8-500 chars (whitespace-stripped length) and must quote at least
13
+ // one grounded evidence item verbatim.
14
+ // * evidence 1-3 items, all grounded; no evidence → no edge (both modes).
15
+ // * confidence ∈ [0.5, 1]; every end ≤20 links; (to, kind) idempotent.
16
+ // * cross-domain only related; rp↔coding never.
17
+ // * links are metadata: they never touch the vector store and never delete
18
+ // memory; removal deletes both ends of the edge by id.
19
+ //
20
+ // Concurrency: validateGlobalLink runs against the caller's index snapshot
21
+ // (fast rejection with a readable reason); applyGlobalLinks re-checks the
22
+ // mutable facts (active, idempotency, caps, cycle) INSIDE the
23
+ // updateGlobalIndex mutator so a concurrent write between snapshot and commit
24
+ // cannot smuggle a bad edge in.
25
+
26
+ import { createHash } from 'node:crypto'
27
+ import { record } from './activity-log.js'
28
+ import { updateGlobalIndex } from './state.js'
29
+ import { redactSecrets } from './paths.js'
30
+ import { groundEvidence, groundLinkNote } from './curator-evidence.js'
31
+ import { globalConflictCheck } from './text-retrieval.js'
32
+ import {
33
+ GLOBAL_LINK_KINDS,
34
+ LINK_CONFIDENCE_MIN,
35
+ LINK_CYCLE_DEPTH,
36
+ LINK_EVIDENCE_MAX,
37
+ LINK_MAX_PER_ENTRY,
38
+ } from './curator-policy.js'
39
+
40
+ function sha116(text) {
41
+ return createHash('sha1').update(String(text)).digest('hex').slice(0, 16)
42
+ }
43
+
44
+ /**
45
+ * §3.2 opId: sha1('link|' + kind + '|' + (symmetric ? sortedPair : from + '>' + to)
46
+ * + '|' + normalize(note)).slice(0,16). Symmetric edges hash the sorted pair so
47
+ * both ends derive the same id regardless of which end the model listed first.
48
+ */
49
+ export function globalLinkOpId({ kind, fromId, toId, note }) {
50
+ const symmetric = kind === 'related'
51
+ const pair = symmetric
52
+ ? [String(fromId), String(toId)].sort().join('|')
53
+ : `${String(fromId)}>${String(toId)}`
54
+ return sha116(`link|${kind}|${pair}|${normalizeForHash(note)}`)
55
+ }
56
+
57
+ function normalizeForHash(text) {
58
+ return String(text || '').normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
59
+ }
60
+
61
+ function isDirected(kind) {
62
+ return kind === 'refines' || kind === 'supports'
63
+ }
64
+
65
+ /**
66
+ * DFS over directed refines/supports edges: can `startId` reach `goalId` in at
67
+ * most maxDepth hops? Used to reject a from→to edge that would close a cycle
68
+ * (to already reaching from).
69
+ */
70
+ export function directedReaches(entries, startId, goalId, maxDepth = LINK_CYCLE_DEPTH) {
71
+ const seen = new Set([startId])
72
+ const walk = (id, depth) => {
73
+ if (depth > maxDepth) return false
74
+ for (const link of entries[id]?.links || []) {
75
+ if (!isDirected(link?.kind)) continue
76
+ const next = String(link.to || '')
77
+ if (!next) continue
78
+ if (next === goalId) return true
79
+ if (seen.has(next)) continue
80
+ seen.add(next)
81
+ if (walk(next, depth + 1)) return true
82
+ }
83
+ return false
84
+ }
85
+ return walk(startId, 1)
86
+ }
87
+
88
+ function fail(reason) {
89
+ return { ok: false, reason }
90
+ }
91
+
92
+ const AUDIT_MAX = 20
93
+
94
+ /** Append one bounded audit event to an entry (link/unlink/absorb undo). */
95
+ export function appendAudit(entry, event) {
96
+ if (!entry) return
97
+ const trail = Array.isArray(entry.auditTrail) ? entry.auditTrail.slice(-(AUDIT_MAX - 1)) : []
98
+ trail.push(event)
99
+ entry.auditTrail = trail
100
+ }
101
+
102
+ /**
103
+ * validateGlobalLink({ index, fromId, toId, kind, note, confidence, evidence, sessionCorpus })
104
+ * → { ok: true, fromId, toId, kind, symmetric, link } | { ok: false, reason }
105
+ *
106
+ * The returned link record is the §3.2 shape, fully prepared for storage:
107
+ * { id: 'lnk_<opId>', opId, to, kind, note, confidence, evidence, createdAt }.
108
+ * The caller applies it via applyGlobalLinks inside the index transaction.
109
+ */
110
+ export function validateGlobalLink({ index, fromId, toId, kind, note, confidence, evidence, sessionCorpus = null, now = null }) {
111
+ const entries = index?.entries || {}
112
+ const from = String(fromId || '').trim()
113
+ const to = String(toId || '').trim()
114
+ const fromEntry = entries[from]
115
+ const toEntry = entries[to]
116
+ if (!fromEntry || !toEntry) return fail(`endpoint not found: ${!fromEntry ? from || '(empty)' : to || '(empty)'}`)
117
+ if (from === to) return fail('self link')
118
+ if (fromEntry.status !== 'active' || toEntry.status !== 'active') {
119
+ return fail(`endpoint not active: ${fromEntry.status !== 'active' ? from : to}`)
120
+ }
121
+ if (!GLOBAL_LINK_KINDS.includes(kind)) return fail(`unknown kind: ${kind ?? '(missing)'}`)
122
+ const noteText = String(note || '')
123
+ const noteLen = noteText.replace(/\s+/g, '').length
124
+ if (noteLen < 8 || noteLen > 500) return fail(`note length ${noteLen} outside 8-500`)
125
+ const list = Array.isArray(evidence) ? evidence : []
126
+ if (list.length < 1 || list.length > LINK_EVIDENCE_MAX) {
127
+ return fail(`evidence must be 1-${LINK_EVIDENCE_MAX} items, got ${list.length}`)
128
+ }
129
+ const grounded = groundEvidence(list, sessionCorpus)
130
+ if (!grounded.valid.length) {
131
+ return fail(`evidence not grounded: ${grounded.invalid[0]?.reason || 'empty'}`)
132
+ }
133
+ if (grounded.invalid.length) {
134
+ return fail(`evidence not grounded: ${grounded.invalid[0]?.reason}`)
135
+ }
136
+ const quoteCheck = groundLinkNote(noteText, grounded.valid)
137
+ if (!quoteCheck.ok) return fail(`note quote: ${quoteCheck.reason}`)
138
+ // §4.8 conflict prefilter: a contradictory pair must go through `conflict` /
139
+ // human review, never become an auto-applied link edge. The prompt rule alone
140
+ // is not a gate (2).
141
+ const conflictSignal = globalConflictCheck(fromEntry, toEntry)
142
+ if (conflictSignal) return fail(`conflict pair detected (${conflictSignal}); use op=conflict instead of link`)
143
+ const conf = Number(confidence)
144
+ if (!Number.isFinite(conf) || conf < LINK_CONFIDENCE_MIN || conf > 1) {
145
+ return fail(`confidence ${Number.isFinite(conf) ? conf : '(nan)'} outside ${LINK_CONFIDENCE_MIN}-1`)
146
+ }
147
+ const fromDomain = fromEntry.domain || 'unknown'
148
+ const toDomain = toEntry.domain || 'unknown'
149
+ if (fromDomain !== toDomain) {
150
+ if (kind !== 'related') return fail('cross-domain links must be related')
151
+ if ((fromDomain === 'rp' && toDomain === 'coding') || (fromDomain === 'coding' && toDomain === 'rp')) {
152
+ return fail('rp and coding never link')
153
+ }
154
+ }
155
+ // idempotency pre-check on the snapshot (re-checked on fresh state in apply)
156
+ if ((fromEntry.links || []).some((l) => l?.to === to && l?.kind === kind)) {
157
+ return fail('duplicate link (idempotent)')
158
+ }
159
+ if (kind === 'related' && (toEntry.links || []).some((l) => l?.to === from && l?.kind === kind)) {
160
+ return fail('duplicate link (idempotent)')
161
+ }
162
+ // cap: only the ends that will carry a record count (directed edges write the source end only)
163
+ if ((fromEntry.links || []).length >= LINK_MAX_PER_ENTRY) return fail(`link cap reached on ${from}`)
164
+ if (kind === 'related' && (toEntry.links || []).length >= LINK_MAX_PER_ENTRY) return fail(`link cap reached on ${to}`)
165
+ if (isDirected(kind) && directedReaches(entries, to, from, LINK_CYCLE_DEPTH)) {
166
+ return fail(`directed cycle: ${to} already reaches ${from} within depth ${LINK_CYCLE_DEPTH}`)
167
+ }
168
+ const opId = globalLinkOpId({ kind, fromId: from, toId: to, note: noteText })
169
+ return {
170
+ ok: true,
171
+ fromId: from,
172
+ toId: to,
173
+ kind,
174
+ symmetric: kind === 'related',
175
+ link: {
176
+ id: `lnk_${opId}`,
177
+ opId,
178
+ to,
179
+ kind,
180
+ note: redactSecrets(noteText),
181
+ confidence: conf,
182
+ evidence: grounded.valid.map((item) => ({ sessionId: item.sessionId, quote: item.quote })),
183
+ createdAt: now || new Date().toISOString(),
184
+ },
185
+ }
186
+ }
187
+
188
+ /**
189
+ * applyGlobalLinks(entries, writes, { now }) — runs INSIDE the
190
+ * updateGlobalIndex mutator; `entries` is fresh.entries and is mutated in
191
+ * place. Re-checks everything a concurrent writer could have changed, then
192
+ * writes: symmetric edges land on both ends sharing id/opId; directed edges
193
+ * only on the source end. One end full → neither end written (§4.11).
194
+ *
195
+ * Returns { applied: [{fromId, toId, kind, linkId}], skipped: [{linkId, reason}] }.
196
+ */
197
+ export function applyGlobalLinks(entries, writes, { now = new Date().toISOString() } = {}) {
198
+ const applied = []
199
+ const skipped = []
200
+ for (const write of writes || []) {
201
+ const linkId = write?.link?.id || null
202
+ const fromEntry = entries[write?.fromId]
203
+ const toEntry = entries[write?.toId]
204
+ if (!fromEntry || !toEntry || fromEntry.status !== 'active' || toEntry.status !== 'active') {
205
+ skipped.push({ linkId, reason: 'endpoint missing or not active' })
206
+ continue
207
+ }
208
+ if (globalConflictCheck(fromEntry, toEntry)) {
209
+ skipped.push({ linkId, reason: 'conflict pair detected' })
210
+ continue
211
+ }
212
+ fromEntry.links = Array.isArray(fromEntry.links) ? fromEntry.links : []
213
+ if (write.symmetric) toEntry.links = Array.isArray(toEntry.links) ? toEntry.links : []
214
+ const duplicateOnFrom = fromEntry.links.some((l) => l?.to === write.toId && l?.kind === write.kind)
215
+ const duplicateOnTo = write.symmetric
216
+ ? toEntry.links.some((l) => l?.to === write.fromId && l?.kind === write.kind)
217
+ : false
218
+ if (duplicateOnFrom || duplicateOnTo) {
219
+ skipped.push({ linkId, reason: 'duplicate link (idempotent)' })
220
+ continue
221
+ }
222
+ const capFrom = fromEntry.links.length >= LINK_MAX_PER_ENTRY
223
+ const capTo = write.symmetric ? toEntry.links.length >= LINK_MAX_PER_ENTRY : false
224
+ if (capFrom || capTo) {
225
+ skipped.push({ linkId, reason: 'link cap reached' })
226
+ continue
227
+ }
228
+ if (isDirected(write.kind) && directedReaches(entries, write.toId, write.fromId, LINK_CYCLE_DEPTH)) {
229
+ skipped.push({ linkId, reason: 'directed cycle' })
230
+ continue
231
+ }
232
+ const recordToStore = { ...write.link, createdAt: write.link?.createdAt || now }
233
+ fromEntry.links.push(recordToStore)
234
+ if (write.symmetric) toEntry.links.push({ ...recordToStore, to: write.fromId })
235
+ const audit = {
236
+ op: 'link', at: recordToStore.createdAt, linkId: recordToStore.id,
237
+ fromId: write.fromId, toId: write.toId, kind: write.kind,
238
+ }
239
+ appendAudit(fromEntry, audit)
240
+ if (write.symmetric) appendAudit(toEntry, { ...audit, fromId: write.toId, toId: write.fromId })
241
+ applied.push({ fromId: write.fromId, toId: write.toId, kind: write.kind, linkId: recordToStore.id })
242
+ }
243
+ return { applied, skipped }
244
+ }
245
+
246
+ /**
247
+ * removeGlobalLink({ linkId, kind? }) — deletes the edge's records from every
248
+ * entry carrying that id (symmetric edges die on both ends at once). Metadata
249
+ * only: statuses, content and vector rows are untouched. Callable from
250
+ * resolveReview's unlink action (F2 wires the rpc branch).
251
+ */
252
+ export function removeGlobalLink({ linkId, kind } = {}) {
253
+ const id = String(linkId || '').trim()
254
+ if (!id) throw new Error('unlink needs a linkId')
255
+ const kindFilter = kind ? String(kind) : null
256
+ let removed = 0
257
+ const entries = []
258
+ updateGlobalIndex((index) => {
259
+ for (const [entryId, entry] of Object.entries(index.entries || {})) {
260
+ if (!Array.isArray(entry?.links) || entry.links.length === 0) continue
261
+ const before = entry.links.length
262
+ entry.links = entry.links.filter((l) => !(l?.id === id && (!kindFilter || l.kind === kindFilter)))
263
+ if (entry.links.length !== before) {
264
+ removed += before - entry.links.length
265
+ entries.push(entryId)
266
+ appendAudit(entry, { op: 'unlink', at: new Date().toISOString(), linkId: id, kind: kindFilter || null })
267
+ }
268
+ }
269
+ return index
270
+ })
271
+ record({
272
+ kind: 'curate',
273
+ scope: 'global',
274
+ ok: removed > 0,
275
+ level: removed > 0 ? 'info' : 'warn',
276
+ message: removed > 0
277
+ ? `全局关联已移除:linkId ${id}(${removed} 条记录,涉及 ${entries.length} 个条目)`
278
+ : `全局关联移除未命中:linkId ${id} 不存在于任何条目`,
279
+ detail: { op: 'unlink', linkId: id, kind: kindFilter, removed, entries: entries.slice(0, 20) },
280
+ })
281
+ return { removed, entries }
282
+ }