@asobacloud/nehanda 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/LICENSE.md +57 -0
- package/README.md +404 -2
- package/bin/agent.mjs +1260 -0
- package/bin/nehanda-ui.mjs +824 -0
- package/lib/anthropicHeaders.mjs +8 -0
- package/lib/app.mjs +313 -0
- package/lib/auth.mjs +255 -0
- package/lib/bashguard.mjs +159 -0
- package/lib/compact.mjs +306 -0
- package/lib/energy-middleware.mjs +318 -0
- package/lib/hookShapeOrder.mjs +30 -0
- package/lib/hookplane.mjs +228 -0
- package/lib/instructions.mjs +23 -0
- package/lib/jev_read.mjs +216 -0
- package/lib/jev_write.mjs +253 -0
- package/lib/laya.mjs +296 -0
- package/lib/laya_sidecar.mjs +198 -0
- package/lib/mcp-config.mjs +189 -0
- package/lib/mcp.mjs +169 -0
- package/lib/modelConfig.mjs +152 -0
- package/lib/modelDiscovery.mjs +205 -0
- package/lib/openaiCompat.mjs +147 -0
- package/lib/orchestrate.mjs +1163 -0
- package/lib/paths.mjs +20 -0
- package/lib/permissions.mjs +44 -0
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
- package/lib/scripts/audit-code-integrity.py +437 -0
- package/lib/scripts/ensure-laya-env.sh +55 -0
- package/lib/scripts/js-safety-checker.cjs +308 -0
- package/lib/scripts/laya_worker.py +171 -0
- package/lib/scripts/odse-transform.py +97 -0
- package/lib/scripts/python-safety-checker.py +197 -0
- package/lib/scripts/shell-safety-checker.sh +306 -0
- package/lib/settings.mjs +147 -0
- package/lib/store.mjs +278 -0
- package/lib/team.mjs +69 -0
- package/lib/testgen.mjs +146 -0
- package/lib/tools/AuditCodeIntegrity.json +30 -0
- package/lib/tools/JsSafetyChecker.json +29 -0
- package/lib/tools/PythonSafetyChecker.json +29 -0
- package/lib/tools/ShellSafetyChecker.json +29 -0
- package/lib/tools.mjs +907 -0
- package/lib/transcript.mjs +223 -0
- package/lib/ui.mjs +286 -0
- package/lib/workflow.mjs +91 -0
- package/lib/worktree.mjs +56 -0
- package/package.json +50 -4
- package/schema.sql +181 -0
- package/scripts/postinstall.mjs +62 -0
- package/scripts/sdlc-acceptance.sh +477 -0
- package/scripts/verify-sdlc-hook-order.mjs +26 -0
- package/templates/test_e2e.sh +32 -0
- package/templates/test_file.sh +14 -0
- package/templates/test_hook.sh +27 -0
- package/templates/test_http.sh +17 -0
- package/templates/test_phase.sh +23 -0
- package/templates/test_process.sh +14 -0
- package/templates/test_tool.sh +24 -0
- package/templates/validate_test.sh +55 -0
package/lib/jev_read.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {
|
|
2
|
+
routeQuery,
|
|
3
|
+
assessEvidence,
|
|
4
|
+
scoreCandidate,
|
|
5
|
+
truncateForLaya,
|
|
6
|
+
} from './laya.mjs'
|
|
7
|
+
|
|
8
|
+
/** Activation threshold θ_act for a relation view. */
|
|
9
|
+
export const THETA_ACT = 0.45
|
|
10
|
+
/** Sufficiency / continue thresholds. */
|
|
11
|
+
export const THETA_SUFF = 0.6
|
|
12
|
+
export const THETA_CONT = 0.45
|
|
13
|
+
/** Total graph expansion budget B and per-round beam W. */
|
|
14
|
+
export const DEFAULT_BUDGET = 12
|
|
15
|
+
export const BEAM_W = 4
|
|
16
|
+
export const MAX_DEPTH = 3
|
|
17
|
+
export const TOP_K_EVIDENCE = 6
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reciprocal rank fusion of lexical (FTS/LIKE) rankings — vector index optional later.
|
|
21
|
+
*/
|
|
22
|
+
export function rrfAnchors(db, query, { sessionId, conversationId, limit = 8 } = {}) {
|
|
23
|
+
const tokens = String(query).toLowerCase().split(/[^a-z0-9_./-]+/).filter(t => t.length > 2).slice(0, 12)
|
|
24
|
+
const ranks = new Map() // id -> { node, rankSum }
|
|
25
|
+
|
|
26
|
+
const bump = (id, node, rank) => {
|
|
27
|
+
const prev = ranks.get(id) || { node, score: 0 }
|
|
28
|
+
prev.score += 1 / (60 + rank)
|
|
29
|
+
prev.node = node
|
|
30
|
+
ranks.set(id, prev)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Lexical list via FTS
|
|
34
|
+
let ftsRows = []
|
|
35
|
+
try {
|
|
36
|
+
const q = tokens.join(' OR ')
|
|
37
|
+
if (q) {
|
|
38
|
+
ftsRows = db.prepare(`
|
|
39
|
+
SELECT m.* FROM memories m
|
|
40
|
+
JOIN memories_fts f ON f.rowid = m.rowid
|
|
41
|
+
WHERE f MATCH ?
|
|
42
|
+
LIMIT ?
|
|
43
|
+
`).all(q, limit * 2)
|
|
44
|
+
}
|
|
45
|
+
} catch { ftsRows = [] }
|
|
46
|
+
|
|
47
|
+
ftsRows.forEach((n, i) => bump(n.id, n, i + 1))
|
|
48
|
+
|
|
49
|
+
// Recency list as second "ranking"
|
|
50
|
+
let recent = []
|
|
51
|
+
try {
|
|
52
|
+
recent = db.prepare(`
|
|
53
|
+
SELECT * FROM memories
|
|
54
|
+
WHERE (? IS NULL OR session_id = ? OR conversation_id = ?)
|
|
55
|
+
ORDER BY observation_ts DESC
|
|
56
|
+
LIMIT ?
|
|
57
|
+
`).all(sessionId || null, sessionId || null, conversationId || null, limit * 2)
|
|
58
|
+
} catch { recent = [] }
|
|
59
|
+
recent.forEach((n, i) => bump(n.id, n, i + 1))
|
|
60
|
+
|
|
61
|
+
// Keyword LIKE fallback if still empty
|
|
62
|
+
if (!ranks.size && tokens.length) {
|
|
63
|
+
const like = `%${tokens[0]}%`
|
|
64
|
+
const rows = db.prepare(`
|
|
65
|
+
SELECT * FROM memories WHERE content LIKE ? OR title LIKE ? LIMIT ?
|
|
66
|
+
`).all(like, like, limit)
|
|
67
|
+
rows.forEach((n, i) => bump(n.id, n, i + 1))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [...ranks.values()].sort((a, b) => b.score - a.score).slice(0, limit)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function neighbors(db, nodeId, relation) {
|
|
74
|
+
return db.prepare(`
|
|
75
|
+
SELECT e.*, m.* FROM memory_edges e
|
|
76
|
+
JOIN memories m ON m.id = CASE WHEN e.src_id = ? THEN e.dst_id ELSE e.src_id END
|
|
77
|
+
WHERE (e.src_id = ? OR e.dst_id = ?) AND e.relation = ?
|
|
78
|
+
ORDER BY e.weight DESC
|
|
79
|
+
LIMIT 20
|
|
80
|
+
`).all(nodeId, nodeId, nodeId, relation)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Adaptive System-1 retrieval closed loop.
|
|
85
|
+
* Returns { evidence, routing, rounds, stoppedReason }.
|
|
86
|
+
*/
|
|
87
|
+
export async function retrieveEvidence(db, query, {
|
|
88
|
+
sessionId = null,
|
|
89
|
+
conversationId = null,
|
|
90
|
+
budget = DEFAULT_BUDGET,
|
|
91
|
+
layaOpts = {},
|
|
92
|
+
topK = TOP_K_EVIDENCE,
|
|
93
|
+
} = {}) {
|
|
94
|
+
const routing = await routeQuery(query, layaOpts)
|
|
95
|
+
const graphs = ['semantic', 'temporal', 'causal', 'entity']
|
|
96
|
+
const active = graphs.filter(g => (routing[g] ?? 0) >= THETA_ACT)
|
|
97
|
+
if (!active.length) active.push('semantic', 'temporal')
|
|
98
|
+
|
|
99
|
+
// Softmax-ish budget weights
|
|
100
|
+
const gamma = 1.5
|
|
101
|
+
const raw = active.map(g => Math.pow(Math.max(routing[g], 1e-3), gamma))
|
|
102
|
+
const sum = raw.reduce((a, b) => a + b, 0) || 1
|
|
103
|
+
const minShare = 1
|
|
104
|
+
let remaining = Math.max(0, budget - minShare * active.length)
|
|
105
|
+
const budgets = {}
|
|
106
|
+
active.forEach((g, i) => {
|
|
107
|
+
budgets[g] = minShare + Math.round(remaining * (raw[i] / sum))
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const depthMax = Math.min(MAX_DEPTH, Math.max(1, Math.ceil(MAX_DEPTH * (routing.multi_hop || 0.3))))
|
|
111
|
+
|
|
112
|
+
const anchors = rrfAnchors(db, query, { sessionId, conversationId, limit: 8 })
|
|
113
|
+
const visited = new Set()
|
|
114
|
+
const evidence = [] // { node, score }
|
|
115
|
+
let frontier = anchors.map(a => ({ node: a.node, score: a.score, via: 'anchor' }))
|
|
116
|
+
|
|
117
|
+
for (const f of frontier) {
|
|
118
|
+
if (visited.has(f.node.id)) continue
|
|
119
|
+
visited.add(f.node.id)
|
|
120
|
+
evidence.push(f)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let stoppedReason = 'budget_exhausted'
|
|
124
|
+
let rounds = 0
|
|
125
|
+
|
|
126
|
+
for (let d = 0; d < depthMax; d++) {
|
|
127
|
+
rounds++
|
|
128
|
+
const evidenceText = truncateForLaya(
|
|
129
|
+
`Query: ${query}\n\nEvidence:\n` +
|
|
130
|
+
evidence.slice(0, 8).map(e => `- ${e.node.title}: ${String(e.node.content || '').slice(0, 120)}`).join('\n'),
|
|
131
|
+
)
|
|
132
|
+
const assessment = await assessEvidence(evidenceText, layaOpts)
|
|
133
|
+
|
|
134
|
+
if (assessment.sufficient >= THETA_SUFF &&
|
|
135
|
+
assessment.missing_required < THETA_CONT &&
|
|
136
|
+
assessment.contradiction < THETA_CONT) {
|
|
137
|
+
stoppedReason = 'sufficient'
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
if (assessment.further_utility < THETA_CONT) {
|
|
141
|
+
stoppedReason = 'low_utility'
|
|
142
|
+
break
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const candidates = []
|
|
146
|
+
for (const g of active) {
|
|
147
|
+
if ((budgets[g] || 0) <= 0) continue
|
|
148
|
+
for (const e of evidence.slice(-BEAM_W * 2)) {
|
|
149
|
+
const neigh = neighbors(db, e.node.id, g)
|
|
150
|
+
for (const n of neigh) {
|
|
151
|
+
if (visited.has(n.id)) continue
|
|
152
|
+
candidates.push({ node: n, via: g, edgeWeight: n.weight ?? 0.5 })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!candidates.length) {
|
|
158
|
+
stoppedReason = 'no_candidates'
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Score candidates (bounded)
|
|
163
|
+
const scored = []
|
|
164
|
+
for (const c of candidates.slice(0, budget)) {
|
|
165
|
+
const ctext = truncateForLaya(
|
|
166
|
+
`Query: ${query}\nCandidate (${c.via}): ${c.node.title}\n${String(c.node.content || '').slice(0, 300)}`,
|
|
167
|
+
)
|
|
168
|
+
const sc = await scoreCandidate(ctext, layaOpts)
|
|
169
|
+
const s =
|
|
170
|
+
0.35 * sc.relevance +
|
|
171
|
+
0.2 * sc.relation_useful * (routing[c.via] || 0.5) +
|
|
172
|
+
0.2 * sc.novelty +
|
|
173
|
+
0.15 * sc.supports_evidence +
|
|
174
|
+
0.1 * (c.edgeWeight || 0)
|
|
175
|
+
scored.push({ node: c.node, score: s, via: c.via })
|
|
176
|
+
budgets[c.via] = (budgets[c.via] || 0) - 1
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
scored.sort((a, b) => b.score - a.score)
|
|
180
|
+
const next = scored.slice(0, BEAM_W)
|
|
181
|
+
if (!next.length) {
|
|
182
|
+
stoppedReason = 'no_candidates'
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
for (const n of next) {
|
|
186
|
+
visited.add(n.node.id)
|
|
187
|
+
evidence.push(n)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
evidence.sort((a, b) => b.score - a.score)
|
|
192
|
+
const top = evidence.slice(0, topK)
|
|
193
|
+
|
|
194
|
+
// Touch access stats
|
|
195
|
+
for (const e of top) {
|
|
196
|
+
try {
|
|
197
|
+
db.prepare(`
|
|
198
|
+
UPDATE memories SET access_count = COALESCE(access_count,0)+1, last_accessed = ? WHERE id = ?
|
|
199
|
+
`).run(Date.now(), e.node.id)
|
|
200
|
+
} catch { /* */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
evidence: top.map(e => ({
|
|
205
|
+
id: e.node.id,
|
|
206
|
+
title: e.node.title,
|
|
207
|
+
content: e.node.content,
|
|
208
|
+
score: e.score,
|
|
209
|
+
via: e.via,
|
|
210
|
+
})),
|
|
211
|
+
routing,
|
|
212
|
+
rounds,
|
|
213
|
+
stoppedReason,
|
|
214
|
+
activeGraphs: active,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
typeMemoryObservation,
|
|
4
|
+
judgeRelations,
|
|
5
|
+
extractCanonicalState,
|
|
6
|
+
truncateForLaya,
|
|
7
|
+
} from './laya.mjs'
|
|
8
|
+
import {
|
|
9
|
+
upsertMemoryNode,
|
|
10
|
+
upsertMemoryEdge,
|
|
11
|
+
upsertLayaJevState,
|
|
12
|
+
withTransaction,
|
|
13
|
+
} from './store.mjs'
|
|
14
|
+
|
|
15
|
+
/** Max candidates for System-1 relation judgment (paper K_w). */
|
|
16
|
+
export const KW_CANDIDATES = 8
|
|
17
|
+
/** Edge insertion threshold θ_rel. */
|
|
18
|
+
export const THETA_REL = 0.55
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Deterministic candidate discovery: lexical overlap + shared entities + temporal proximity.
|
|
22
|
+
* Does NOT call Laya — keeps controller cost O(K_w), not O(|V|).
|
|
23
|
+
*/
|
|
24
|
+
export function discoverCandidates(db, node, limit = KW_CANDIDATES) {
|
|
25
|
+
const sessionId = node.session_id
|
|
26
|
+
const entities = parseEntities(node.entities_json)
|
|
27
|
+
const entitySet = new Set(entities.map(e => String(e).toLowerCase()))
|
|
28
|
+
const tokens = tokenize(node.content || node.title || '')
|
|
29
|
+
|
|
30
|
+
let rows = []
|
|
31
|
+
try {
|
|
32
|
+
// Prefer FTS when available
|
|
33
|
+
const q = tokens.slice(0, 8).join(' OR ')
|
|
34
|
+
if (q) {
|
|
35
|
+
rows = db.prepare(`
|
|
36
|
+
SELECT m.* FROM memories m
|
|
37
|
+
JOIN memories_fts f ON f.rowid = m.rowid
|
|
38
|
+
WHERE f MATCH ? AND m.id != ?
|
|
39
|
+
LIMIT ?
|
|
40
|
+
`).all(q, node.id, limit * 3)
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
rows = []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!rows.length) {
|
|
47
|
+
rows = db.prepare(`
|
|
48
|
+
SELECT * FROM memories
|
|
49
|
+
WHERE id != ? AND (session_id = ? OR conversation_id = ?)
|
|
50
|
+
ORDER BY observation_ts DESC
|
|
51
|
+
LIMIT ?
|
|
52
|
+
`).all(node.id, sessionId, node.conversation_id, limit * 4)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const scored = rows.map(u => {
|
|
56
|
+
let s = 0
|
|
57
|
+
const uEnt = parseEntities(u.entities_json).map(e => String(e).toLowerCase())
|
|
58
|
+
for (const e of uEnt) if (entitySet.has(e)) s += 2
|
|
59
|
+
const uTok = new Set(tokenize(u.content || u.title || ''))
|
|
60
|
+
let overlap = 0
|
|
61
|
+
for (const t of tokens) if (uTok.has(t)) overlap++
|
|
62
|
+
s += Math.min(overlap, 10) * 0.3
|
|
63
|
+
if (node.observation_ts && u.observation_ts) {
|
|
64
|
+
const dt = Math.abs(node.observation_ts - u.observation_ts)
|
|
65
|
+
if (dt < 60_000) s += 1.5
|
|
66
|
+
else if (dt < 600_000) s += 0.5
|
|
67
|
+
}
|
|
68
|
+
return { node: u, score: s }
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
scored.sort((a, b) => b.score - a.score)
|
|
72
|
+
return scored.slice(0, limit).map(x => x.node)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function tokenize(text) {
|
|
76
|
+
return String(text).toLowerCase().split(/[^a-z0-9_./-]+/).filter(t => t.length > 2).slice(0, 40)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseEntities(raw) {
|
|
80
|
+
if (!raw) return []
|
|
81
|
+
if (Array.isArray(raw)) return raw
|
|
82
|
+
try {
|
|
83
|
+
const v = JSON.parse(raw)
|
|
84
|
+
return Array.isArray(v) ? v : []
|
|
85
|
+
} catch {
|
|
86
|
+
return []
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Extract path-like entities from observation text / tool metadata. */
|
|
91
|
+
export function extractEntities(text, extra = []) {
|
|
92
|
+
const found = new Set(extra.filter(Boolean))
|
|
93
|
+
const re = /(?:^|[\s`"'(])((?:\.\/|\/)?(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+\.[A-Za-z0-9]+)/g
|
|
94
|
+
const s = String(text || '')
|
|
95
|
+
let m
|
|
96
|
+
while ((m = re.exec(s))) found.add(m[1])
|
|
97
|
+
return [...found]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Write path: observation → type → candidates → relations → memory plane + session state.
|
|
102
|
+
* Observations are always preserved (no discard-on-write).
|
|
103
|
+
*/
|
|
104
|
+
export async function ingestObservation(db, {
|
|
105
|
+
sessionId,
|
|
106
|
+
conversationId,
|
|
107
|
+
text,
|
|
108
|
+
title,
|
|
109
|
+
provenance = 'transcript',
|
|
110
|
+
sourceEntryId = null,
|
|
111
|
+
filePaths = [],
|
|
112
|
+
observationTs = Date.now(),
|
|
113
|
+
layaOpts = {},
|
|
114
|
+
updateSessionState = true,
|
|
115
|
+
}) {
|
|
116
|
+
const content = String(text || '')
|
|
117
|
+
const entities = extractEntities(content, filePaths)
|
|
118
|
+
const typeScores = await typeMemoryObservation(truncateForLaya(content), layaOpts)
|
|
119
|
+
|
|
120
|
+
const id = randomUUID()
|
|
121
|
+
const node = {
|
|
122
|
+
id,
|
|
123
|
+
type: 'jev_observation',
|
|
124
|
+
title: title || content.slice(0, 80),
|
|
125
|
+
content,
|
|
126
|
+
keywords: entities.join(','),
|
|
127
|
+
session_id: sessionId,
|
|
128
|
+
conversation_id: conversationId,
|
|
129
|
+
provenance,
|
|
130
|
+
entities_json: entities,
|
|
131
|
+
t_episodic: typeScores.t_episodic,
|
|
132
|
+
t_semantic: typeScores.t_semantic,
|
|
133
|
+
t_procedural: typeScores.t_procedural,
|
|
134
|
+
t_preference: typeScores.t_preference,
|
|
135
|
+
source_entry_id: sourceEntryId,
|
|
136
|
+
observation_ts: observationTs,
|
|
137
|
+
created_at: observationTs,
|
|
138
|
+
updated_at: observationTs,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
withTransaction(db, () => {
|
|
142
|
+
upsertMemoryNode(db, node)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const candidates = discoverCandidates(db, { ...node, entities_json: JSON.stringify(entities) })
|
|
146
|
+
|
|
147
|
+
// Structural temporal edges (no Laya): newer → older as temporal with weight 1
|
|
148
|
+
for (const u of candidates) {
|
|
149
|
+
if (node.observation_ts && u.observation_ts && node.observation_ts !== u.observation_ts) {
|
|
150
|
+
const [src, dst] = node.observation_ts >= u.observation_ts ? [node.id, u.id] : [u.id, node.id]
|
|
151
|
+
upsertMemoryEdge(db, {
|
|
152
|
+
src_id: src,
|
|
153
|
+
dst_id: dst,
|
|
154
|
+
relation: 'temporal',
|
|
155
|
+
weight: 1,
|
|
156
|
+
meta_json: { kind: 'timestamp_order' },
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
// Exact shared entity → entity edge without Laya
|
|
160
|
+
const uEnt = new Set(parseEntities(u.entities_json).map(e => e.toLowerCase()))
|
|
161
|
+
const shared = entities.filter(e => uEnt.has(String(e).toLowerCase()))
|
|
162
|
+
if (shared.length) {
|
|
163
|
+
upsertMemoryEdge(db, {
|
|
164
|
+
src_id: node.id,
|
|
165
|
+
dst_id: u.id,
|
|
166
|
+
relation: 'entity',
|
|
167
|
+
weight: Math.min(1, 0.5 + shared.length * 0.2),
|
|
168
|
+
meta_json: { shared },
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Laya relation judgments for TopK pairs (semantic / causal); entity/same_episode when needed
|
|
174
|
+
for (const u of candidates) {
|
|
175
|
+
const pair = truncateForLaya(
|
|
176
|
+
`Memory A:\n${content.slice(0, 400)}\n\nMemory B:\n${String(u.content || '').slice(0, 400)}`,
|
|
177
|
+
)
|
|
178
|
+
const rel = await judgeRelations(pair, layaOpts)
|
|
179
|
+
if (rel.semantic >= THETA_REL) {
|
|
180
|
+
upsertMemoryEdge(db, { src_id: node.id, dst_id: u.id, relation: 'semantic', weight: rel.semantic })
|
|
181
|
+
}
|
|
182
|
+
if (rel.causal >= THETA_REL) {
|
|
183
|
+
upsertMemoryEdge(db, { src_id: node.id, dst_id: u.id, relation: 'causal', weight: rel.causal })
|
|
184
|
+
}
|
|
185
|
+
// entity via Laya only if we didn't already create a structural entity edge
|
|
186
|
+
if (rel.entity >= THETA_REL) {
|
|
187
|
+
upsertMemoryEdge(db, { src_id: node.id, dst_id: u.id, relation: 'entity', weight: rel.entity })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let sessionState = null
|
|
192
|
+
if (updateSessionState) {
|
|
193
|
+
const extracted = await extractCanonicalState(content, filePaths, layaOpts)
|
|
194
|
+
sessionState = upsertLayaJevState(db, sessionId, extracted)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { node: db.prepare(`SELECT * FROM memories WHERE id = ?`).get(id), sessionState, candidateCount: candidates.length }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Extract recent file paths from transcript tool uses (Read/Edit/Write).
|
|
202
|
+
*/
|
|
203
|
+
export function extractRecentFilePaths(db, sessionId, limit = 20) {
|
|
204
|
+
const rows = db.prepare(`
|
|
205
|
+
SELECT entry_type, payload_json FROM transcript_entries
|
|
206
|
+
WHERE session_id = ? ORDER BY sequence ASC
|
|
207
|
+
`).all(sessionId)
|
|
208
|
+
|
|
209
|
+
const paths = []
|
|
210
|
+
for (const row of rows) {
|
|
211
|
+
try {
|
|
212
|
+
const payload = JSON.parse(row.payload_json)
|
|
213
|
+
if (row.entry_type !== 'assistant') continue
|
|
214
|
+
for (const b of payload.content || []) {
|
|
215
|
+
if (b?.type !== 'tool_use') continue
|
|
216
|
+
if (!['Read', 'Edit', 'Write', 'MultiEdit'].includes(b.name)) continue
|
|
217
|
+
const p = b.input?.path || b.input?.file_path
|
|
218
|
+
if (p) paths.push(p)
|
|
219
|
+
}
|
|
220
|
+
} catch { /* */ }
|
|
221
|
+
}
|
|
222
|
+
return paths.slice(-limit)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Build a short text tail from recent transcript entries for Laya state extraction.
|
|
227
|
+
*/
|
|
228
|
+
export function transcriptTailText(db, sessionId, maxChars = 1200) {
|
|
229
|
+
const rows = db.prepare(`
|
|
230
|
+
SELECT entry_type, payload_json FROM transcript_entries
|
|
231
|
+
WHERE session_id = ? ORDER BY sequence DESC LIMIT 30
|
|
232
|
+
`).all(sessionId)
|
|
233
|
+
|
|
234
|
+
const parts = []
|
|
235
|
+
for (const row of rows) {
|
|
236
|
+
try {
|
|
237
|
+
const p = JSON.parse(row.payload_json)
|
|
238
|
+
if (row.entry_type === 'user') {
|
|
239
|
+
const t = (p.content || []).filter(b => b.type === 'text').map(b => b.text).join('\n')
|
|
240
|
+
if (t) parts.push(`User: ${t}`)
|
|
241
|
+
} else if (row.entry_type === 'assistant') {
|
|
242
|
+
const t = (p.content || []).filter(b => b.type === 'text').map(b => b.text).join('\n')
|
|
243
|
+
if (t) parts.push(`Assistant: ${t.slice(0, 200)}`)
|
|
244
|
+
} else if (row.entry_type === 'tool_result') {
|
|
245
|
+
const c = typeof p.content === 'string' ? p.content : JSON.stringify(p.content ?? '')
|
|
246
|
+
const flag = p.is_error ? 'ERROR' : 'ok'
|
|
247
|
+
parts.push(`Tool(${flag}): ${c.slice(0, 200)}`)
|
|
248
|
+
}
|
|
249
|
+
} catch { /* */ }
|
|
250
|
+
}
|
|
251
|
+
parts.reverse()
|
|
252
|
+
return truncateForLaya(parts.join('\n'), maxChars)
|
|
253
|
+
}
|