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.
- package/AGENTS.md +1 -1
- package/README.md +19 -8
- package/README.zh.md +12 -6
- package/docs/contributing.md +16 -3
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -3
- package/lib/activity-log.js +22 -5
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +3 -3
- package/lib/client.js +1610 -117
- package/lib/consolidation-worker.js +68 -17
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1299 -83
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +190 -14
- package/lib/inbound.js +19 -0
- package/lib/index.js +33 -1
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +585 -76
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +128 -7
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +622 -13
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +124 -0
- package/lib/tools.js +230 -16
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/scripts/replay.mjs +573 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +144 -10
- package/tests/consolidation-snapshot.test.mjs +82 -4
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +324 -0
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +223 -0
- package/tests/graph.test.mjs +113 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +446 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui-auth.test.mjs +348 -2
- 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 =
|
|
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.
|
|
175
|
-
if (
|
|
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 =
|
|
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
|
+
}
|