@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +57 -0
  3. package/README.md +404 -2
  4. package/bin/agent.mjs +1260 -0
  5. package/bin/nehanda-ui.mjs +824 -0
  6. package/lib/anthropicHeaders.mjs +8 -0
  7. package/lib/app.mjs +313 -0
  8. package/lib/auth.mjs +255 -0
  9. package/lib/bashguard.mjs +159 -0
  10. package/lib/compact.mjs +306 -0
  11. package/lib/energy-middleware.mjs +318 -0
  12. package/lib/hookShapeOrder.mjs +30 -0
  13. package/lib/hookplane.mjs +228 -0
  14. package/lib/instructions.mjs +23 -0
  15. package/lib/jev_read.mjs +216 -0
  16. package/lib/jev_write.mjs +253 -0
  17. package/lib/laya.mjs +296 -0
  18. package/lib/laya_sidecar.mjs +198 -0
  19. package/lib/mcp-config.mjs +189 -0
  20. package/lib/mcp.mjs +169 -0
  21. package/lib/modelConfig.mjs +152 -0
  22. package/lib/modelDiscovery.mjs +205 -0
  23. package/lib/openaiCompat.mjs +147 -0
  24. package/lib/orchestrate.mjs +1163 -0
  25. package/lib/paths.mjs +20 -0
  26. package/lib/permissions.mjs +44 -0
  27. package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
  28. package/lib/scripts/audit-code-integrity.py +437 -0
  29. package/lib/scripts/ensure-laya-env.sh +55 -0
  30. package/lib/scripts/js-safety-checker.cjs +308 -0
  31. package/lib/scripts/laya_worker.py +171 -0
  32. package/lib/scripts/odse-transform.py +97 -0
  33. package/lib/scripts/python-safety-checker.py +197 -0
  34. package/lib/scripts/shell-safety-checker.sh +306 -0
  35. package/lib/settings.mjs +147 -0
  36. package/lib/store.mjs +278 -0
  37. package/lib/team.mjs +69 -0
  38. package/lib/testgen.mjs +146 -0
  39. package/lib/tools/AuditCodeIntegrity.json +30 -0
  40. package/lib/tools/JsSafetyChecker.json +29 -0
  41. package/lib/tools/PythonSafetyChecker.json +29 -0
  42. package/lib/tools/ShellSafetyChecker.json +29 -0
  43. package/lib/tools.mjs +907 -0
  44. package/lib/transcript.mjs +223 -0
  45. package/lib/ui.mjs +286 -0
  46. package/lib/workflow.mjs +91 -0
  47. package/lib/worktree.mjs +56 -0
  48. package/package.json +50 -4
  49. package/schema.sql +181 -0
  50. package/scripts/postinstall.mjs +62 -0
  51. package/scripts/sdlc-acceptance.sh +477 -0
  52. package/scripts/verify-sdlc-hook-order.mjs +26 -0
  53. package/templates/test_e2e.sh +32 -0
  54. package/templates/test_file.sh +14 -0
  55. package/templates/test_hook.sh +27 -0
  56. package/templates/test_http.sh +17 -0
  57. package/templates/test_phase.sh +23 -0
  58. package/templates/test_process.sh +14 -0
  59. package/templates/test_tool.sh +24 -0
  60. package/templates/validate_test.sh +55 -0
@@ -0,0 +1,159 @@
1
+ import path from 'node:path'
2
+
3
+ /**
4
+ * Validate a bash command before execution.
5
+ * Blocks destructive commands everywhere and filesystem writes outside cwd.
6
+ * Returns { ok: true } or { ok: false, reason: string }.
7
+ */
8
+ export function validateBashCommand(cmd, cwd) {
9
+ // Split on pipes, semicolons, &&, || to check each segment
10
+ const segments = cmd.split(/\s*[;|&]{1,2}\s*/).map(s => s.trim()).filter(Boolean)
11
+ for (const seg of segments) {
12
+ const check = checkSegment(seg, cwd)
13
+ if (!check.ok) return check
14
+ }
15
+ // Check subshells $(...) and backticks
16
+ const subshells = [...cmd.matchAll(/\$\(([^)]+)\)/g), ...cmd.matchAll(/`([^`]+)`/g)]
17
+ for (const m of subshells) {
18
+ const check = checkSegment(m[1].trim(), cwd)
19
+ if (!check.ok) return check
20
+ }
21
+ return { ok: true }
22
+ }
23
+
24
+ function checkSegment(seg, cwd) {
25
+ const lower = seg.toLowerCase()
26
+ const tokens = seg.split(/\s+/)
27
+
28
+ // ── Category 1: Always-blocked destructive commands ──
29
+
30
+ // rm -rf with dangerous targets
31
+ if (/\brm\b/.test(seg) && /-(r|rf|fr)\b/.test(seg)) {
32
+ const rmTargets = tokens.filter(t => !t.startsWith('-') && t !== 'rm')
33
+ for (const t of rmTargets) {
34
+ const resolved = resolvePath(t, cwd)
35
+ if (isDangerousPath(resolved, cwd)) {
36
+ return { ok: false, reason: `rm -rf targeting dangerous path: ${t}` }
37
+ }
38
+ }
39
+ }
40
+
41
+ // git destructive operations
42
+ if (/\bgit\s+checkout\s+--/.test(seg)) return { ok: false, reason: 'git checkout -- discards changes (destructive)' }
43
+ if (/\bgit\s+reset\s+--hard/.test(seg)) return { ok: false, reason: 'git reset --hard (destructive)' }
44
+ if (/\bgit\s+push\s+(-f|--force)/.test(seg)) return { ok: false, reason: 'git push --force (destructive)' }
45
+ if (/\bgit\s+clean\s+-[fd]/.test(seg)) return { ok: false, reason: 'git clean (destructive)' }
46
+ if (/\bgit\s+branch\s+-D\b/.test(seg)) return { ok: false, reason: 'git branch -D (destructive)' }
47
+ if (/\bgit\s+stash\s+drop/.test(seg)) return { ok: false, reason: 'git stash drop (destructive)' }
48
+ if (/\bgit\s+reflog\s+expire/.test(seg)) return { ok: false, reason: 'git reflog expire (destructive)' }
49
+
50
+ // System-level destructive commands
51
+ if (/\bmkfs\b/.test(lower)) return { ok: false, reason: 'mkfs (destructive)' }
52
+ if (/\bdd\s+if=/.test(lower)) return { ok: false, reason: 'dd (destructive)' }
53
+ if (/\bformat\b/.test(lower) && /\/dev\//.test(seg)) return { ok: false, reason: 'format device (destructive)' }
54
+ if (/\bkillall\b/.test(lower)) return { ok: false, reason: 'killall (destructive)' }
55
+ if (/\bkill\s+-9\b/.test(seg)) return { ok: false, reason: 'kill -9 (destructive)' }
56
+ if (/\bsudo\b/.test(lower)) return { ok: false, reason: 'sudo not permitted' }
57
+ if (/\bsu\s/.test(lower)) return { ok: false, reason: 'su not permitted' }
58
+
59
+ // ── Category 2: Filesystem writes outside cwd ──
60
+
61
+ // Redirects to absolute paths
62
+ const redirects = [...seg.matchAll(/>{1,2}\s*(\S+)/g)]
63
+ for (const m of redirects) {
64
+ const target = m[1]
65
+ if (target === '/dev/null') continue
66
+ const resolved = resolvePath(target, cwd)
67
+ if (!isUnderCwd(resolved, cwd)) {
68
+ return { ok: false, reason: `Write redirect to path outside working directory: ${target}` }
69
+ }
70
+ }
71
+
72
+ // tee to absolute paths outside cwd
73
+ if (/\btee\b/.test(seg)) {
74
+ const teeArgs = tokens.slice(tokens.indexOf('tee') + 1).filter(t => !t.startsWith('-'))
75
+ for (const t of teeArgs) {
76
+ const resolved = resolvePath(t, cwd)
77
+ if (!isUnderCwd(resolved, cwd)) {
78
+ return { ok: false, reason: `tee to path outside working directory: ${t}` }
79
+ }
80
+ }
81
+ }
82
+
83
+ // cp, mv, ln with targets outside cwd
84
+ for (const cmd of ['cp', 'mv', 'ln']) {
85
+ if (new RegExp(`\\b${cmd}\\b`).test(seg)) {
86
+ const args = tokens.slice(tokens.indexOf(cmd) + 1).filter(t => !t.startsWith('-'))
87
+ if (args.length >= 2) {
88
+ const target = args[args.length - 1]
89
+ const resolved = resolvePath(target, cwd)
90
+ if (!isUnderCwd(resolved, cwd)) {
91
+ return { ok: false, reason: `${cmd} target outside working directory: ${target}` }
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ // mkdir, touch with paths outside cwd
98
+ for (const cmd of ['mkdir', 'touch']) {
99
+ if (new RegExp(`\\b${cmd}\\b`).test(seg)) {
100
+ const args = tokens.slice(tokens.indexOf(cmd) + 1).filter(t => !t.startsWith('-'))
101
+ for (const a of args) {
102
+ const resolved = resolvePath(a, cwd)
103
+ if (!isUnderCwd(resolved, cwd)) {
104
+ return { ok: false, reason: `${cmd} outside working directory: ${a}` }
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ // sed -i on files outside cwd
111
+ if (/\bsed\b.*-i/.test(seg)) {
112
+ const sedArgs = tokens.filter(t => !t.startsWith('-') && t !== 'sed' && !t.startsWith('s/') && !t.startsWith("'"))
113
+ for (const a of sedArgs) {
114
+ const resolved = resolvePath(a, cwd)
115
+ if (!isUnderCwd(resolved, cwd)) {
116
+ return { ok: false, reason: `sed -i on file outside working directory: ${a}` }
117
+ }
118
+ }
119
+ }
120
+
121
+ // chmod/chown -R with paths outside cwd
122
+ for (const cmd of ['chmod', 'chown']) {
123
+ if (new RegExp(`\\b${cmd}\\b`).test(seg) && /-R/.test(seg)) {
124
+ const args = tokens.filter(t => !t.startsWith('-') && t !== cmd)
125
+ for (const a of args) {
126
+ if (/^[0-7]+$/.test(a) || /^[a-z]+:[a-z]+$/i.test(a)) continue // skip mode/owner args
127
+ const resolved = resolvePath(a, cwd)
128
+ if (!isUnderCwd(resolved, cwd)) {
129
+ return { ok: false, reason: `${cmd} -R outside working directory: ${a}` }
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ return { ok: true }
136
+ }
137
+
138
+ function resolvePath(p, cwd) {
139
+ if (!p) return cwd
140
+ if (p.startsWith('~')) p = path.join(process.env.HOME || '/root', p.slice(1))
141
+ if (p.startsWith('/')) return path.resolve(p)
142
+ return path.resolve(cwd, p)
143
+ }
144
+
145
+ function isUnderCwd(resolved, cwd) {
146
+ const normalCwd = path.resolve(cwd) + path.sep
147
+ const normalResolved = path.resolve(resolved)
148
+ return normalResolved === path.resolve(cwd) || normalResolved.startsWith(normalCwd)
149
+ }
150
+
151
+ function isDangerousPath(resolved, cwd) {
152
+ // Paths that are never safe to rm -rf
153
+ const dangerous = ['/', '/home', '/Users', '/tmp', '/var', '/etc', '/usr', '/bin', '/sbin', '/opt', '/System', '/Library']
154
+ if (dangerous.includes(resolved)) return true
155
+ if (resolved === process.env.HOME) return true
156
+ // Outside cwd is dangerous for rm -rf
157
+ if (!isUnderCwd(resolved, cwd)) return true
158
+ return false
159
+ }
@@ -0,0 +1,306 @@
1
+ import { appendEntry } from './transcript.mjs'
2
+ import { withTransaction, getLayaJevState, upsertLayaJevState } from './store.mjs'
3
+ import { runHooks } from './hookplane.mjs'
4
+ import { extractCanonicalState } from './laya.mjs'
5
+ import {
6
+ transcriptTailText,
7
+ extractRecentFilePaths,
8
+ ingestObservation,
9
+ } from './jev_write.mjs'
10
+ import { retrieveEvidence } from './jev_read.mjs'
11
+
12
+ /**
13
+ * Compact a conversation by summarizing old messages into a collapse_commit entry.
14
+ * Old entries are NOT deleted — they remain for full recall.
15
+ * The transcript builders detect collapse_commit and skip prior entries.
16
+ *
17
+ * When Jev-Mem is available, prefers a canonical System-1 header over free-text
18
+ * System-2 prose summaries (see compactWithJev / compactConversationWithJev).
19
+ */
20
+ export async function compactConversation(db, rt, summarize, log) {
21
+ const hookRt = {
22
+ sessionId: rt.sessionId, conversationId: rt.conversationId,
23
+ runtimeDbPath: rt.runtimeDbPath, cwd: rt.cwd,
24
+ permissionMode: rt.settings?.permissions?.defaultMode ?? 'default',
25
+ settings: rt.settings,
26
+ }
27
+
28
+ const lastCollapse = db.prepare(
29
+ `SELECT sequence FROM transcript_entries WHERE session_id = ? AND entry_type = 'collapse_commit' ORDER BY sequence DESC LIMIT 1`
30
+ ).get(rt.sessionId)
31
+ const startSeq = lastCollapse ? lastCollapse.sequence + 1 : 0
32
+
33
+ const entries = db.prepare(
34
+ `SELECT entry_type, payload_json FROM transcript_entries WHERE session_id = ? AND sequence >= ? AND entry_type != 'collapse_commit' ORDER BY sequence ASC`
35
+ ).all(rt.sessionId, startSeq)
36
+
37
+ if (entries.length < 4) {
38
+ log('Not enough messages to compact.')
39
+ return null
40
+ }
41
+
42
+ await runHooks(db, hookRt, 'PreCompact', { trigger: 'manual' })
43
+
44
+ log('Compacting conversation (Jev-Mem)...')
45
+ let summary
46
+ try {
47
+ summary = await buildJevCollapseSummary(db, rt)
48
+ } catch {
49
+ const text = entries.map(e => {
50
+ try {
51
+ const p = JSON.parse(e.payload_json)
52
+ if (e.entry_type === 'user') return `User: ${extractText(p)}`
53
+ if (e.entry_type === 'assistant') return `Assistant: ${extractText(p)}`
54
+ if (e.entry_type === 'tool_result') return `Tool result: ${p.content?.slice?.(0, 200) || ''}`
55
+ return ''
56
+ } catch { return '' }
57
+ }).filter(Boolean).join('\n')
58
+ log('Jev compact failed; falling back to summarizer.')
59
+ summary = await summarize(text)
60
+ }
61
+
62
+ withTransaction(db, () => {
63
+ appendEntry(db, rt.sessionId, 'collapse_commit', {
64
+ _t: 'collapse_commit',
65
+ summary,
66
+ compacted_count: entries.length,
67
+ compacted_from_sequence: startSeq,
68
+ jev: true,
69
+ })
70
+
71
+ db.prepare(
72
+ `INSERT OR REPLACE INTO summaries(conversation_id, content, word_count) VALUES (?, ?, ?)`
73
+ ).run(rt.conversationId, summary, summary.split(/\s+/).length)
74
+ })
75
+
76
+ await runHooks(db, hookRt, 'PostCompact', { trigger: 'manual', compact_summary: summary })
77
+
78
+ log(`Compacted ${entries.length} entries.`)
79
+ return summary
80
+ }
81
+
82
+ async function buildJevCollapseSummary(db, rt) {
83
+ const paths = extractRecentFilePaths(db, rt.sessionId)
84
+ const tail = transcriptTailText(db, rt.sessionId)
85
+ const state = await extractCanonicalState(tail, paths, rt.layaOpts || {})
86
+ upsertLayaJevState(db, rt.sessionId, state)
87
+ const retrieved = await retrieveEvidence(db, tail || 'continue the current task', {
88
+ sessionId: rt.sessionId,
89
+ conversationId: rt.conversationId,
90
+ layaOpts: rt.layaOpts || {},
91
+ })
92
+ return formatCanonicalHeader(state) + '\n' + formatEvidenceBlock(retrieved.evidence)
93
+ }
94
+
95
+ export function formatCanonicalHeader(layaState) {
96
+ if (!layaState) {
97
+ return `[CANONICAL SYSTEM 1 STATE (Laya)]\n- Task Status: unknown\n- Target File: (none)\n- Escalation Flag: FALSE\n--------------------------------------------------`
98
+ }
99
+ const esc = layaState.escalation_risk ? 'TRUE' : 'FALSE'
100
+ const conf = Number(layaState.confidence_score || 0)
101
+ const confStr = Number.isFinite(conf) ? ` (p=${conf.toFixed(2)})` : ''
102
+ return [
103
+ '[CANONICAL SYSTEM 1 STATE (Laya 421M)]',
104
+ `- Task Status: ${layaState.task_status || 'investigating'}${confStr}`,
105
+ `- Target File: ${layaState.file_target || '(none)'}`,
106
+ `- Escalation Flag: ${esc}`,
107
+ '--------------------------------------------------',
108
+ ].join('\n')
109
+ }
110
+
111
+ export function formatEvidenceBlock(evidence = []) {
112
+ if (!evidence.length) return '[RETRIEVED EVIDENCE]\n(none)\n--------------------------------------------------'
113
+ const lines = evidence.slice(0, 6).map((e, i) => {
114
+ const body = String(e.content || '').replace(/\s+/g, ' ').slice(0, 160)
115
+ return `${i + 1}. (${e.via || 'mem'}) ${e.title || e.id}: ${body}`
116
+ })
117
+ return ['[RETRIEVED EVIDENCE]', ...lines, '--------------------------------------------------'].join('\n')
118
+ }
119
+
120
+ /**
121
+ * Rough token estimate (~4 chars/token) for behavioral budgets.
122
+ */
123
+ export function estimateTokens(text) {
124
+ return Math.ceil(String(text || '').length / 4)
125
+ }
126
+
127
+ export function estimateMessagesTokens(messages) {
128
+ let n = 0
129
+ for (const m of messages || []) {
130
+ if (typeof m.content === 'string') n += estimateTokens(m.content)
131
+ else if (Array.isArray(m.content)) {
132
+ for (const b of m.content) {
133
+ if (typeof b === 'string') n += estimateTokens(b)
134
+ else if (b?.text) n += estimateTokens(b.text)
135
+ else if (b?.content) n += estimateTokens(typeof b.content === 'string' ? b.content : JSON.stringify(b.content))
136
+ else n += estimateTokens(JSON.stringify(b))
137
+ }
138
+ } else if (m.tool_calls) {
139
+ n += estimateTokens(JSON.stringify(m.tool_calls))
140
+ }
141
+ }
142
+ return n
143
+ }
144
+
145
+ /**
146
+ * Find index ranges for "last N user turns" in an OpenAI-style message list.
147
+ * A turn starts at a user message that is not solely tool_result packing.
148
+ */
149
+ export function selectTrailingMessages(messages, { turns = 2, includeLastFailedTool = true } = {}) {
150
+ if (!Array.isArray(messages) || !messages.length) return []
151
+ const userIdxs = []
152
+ for (let i = 0; i < messages.length; i++) {
153
+ if (messages[i].role === 'user') userIdxs.push(i)
154
+ }
155
+ const startUser = userIdxs.length <= turns
156
+ ? (userIdxs[0] ?? 0)
157
+ : userIdxs[userIdxs.length - turns]
158
+
159
+ let slice = messages.slice(startUser)
160
+
161
+ if (includeLastFailedTool) {
162
+ let lastFail = -1
163
+ for (let i = 0; i < messages.length; i++) {
164
+ const m = messages[i]
165
+ if (m.role === 'tool' && isFailedToolContent(m.content)) lastFail = i
166
+ if (m.role === 'user' && Array.isArray(m.content)) {
167
+ for (const b of m.content) {
168
+ if (b?.type === 'tool_result' && b.is_error) lastFail = i
169
+ }
170
+ }
171
+ }
172
+ if (lastFail >= 0 && lastFail < startUser) {
173
+ // Include the failed tool message and its preceding assistant tool_call if present
174
+ let from = lastFail
175
+ if (from > 0 && messages[from - 1]?.role === 'assistant') from = from - 1
176
+ const failSlice = messages.slice(from, lastFail + 1)
177
+ // Dedupe if already inside trailing slice
178
+ const ids = new Set(slice.map((_, i) => startUser + i))
179
+ const extra = []
180
+ for (let i = from; i <= lastFail; i++) {
181
+ if (!ids.has(i)) extra.push(messages[i])
182
+ }
183
+ slice = [...extra, ...slice]
184
+ void failSlice
185
+ }
186
+ }
187
+
188
+ return slice
189
+ }
190
+
191
+ function isFailedToolContent(content) {
192
+ const s = typeof content === 'string' ? content : JSON.stringify(content ?? '')
193
+ return /\b(FAILED|is_error\s*[:=]\s*true|exit code [1-9]\d*|permission denied|ENOENT|Command failed)\b/i.test(s)
194
+ }
195
+
196
+ /**
197
+ * Build System-2 message list: canonical header + evidence + last 2 turns + last failed tool.
198
+ * Tool payloads in the trailing window are capped so prefill stays bounded.
199
+ */
200
+ export function compactWithJev(messages, layaState, evidence = [], opts = {}) {
201
+ const header = formatCanonicalHeader(layaState)
202
+ const ev = formatEvidenceBlock(evidence)
203
+ const preamble = `${header}\n${ev}`
204
+ const trailing = selectTrailingMessages(messages, {
205
+ turns: opts.turns ?? 2,
206
+ includeLastFailedTool: opts.includeLastFailedTool !== false,
207
+ })
208
+
209
+ const body = truncateTrailingPayloads(trailing.length ? trailing : (messages || []).slice(-4), opts)
210
+
211
+ return [
212
+ { role: 'user', content: preamble },
213
+ { role: 'assistant', content: 'Acknowledged. Continuing from canonical System-1 state and retrieved evidence.' },
214
+ ...body,
215
+ ]
216
+ }
217
+
218
+ const TOOL_CAP_CHARS = 280
219
+ const FAILED_TOOL_CAP_CHARS = 480
220
+ const TEXT_CAP_CHARS = 400
221
+
222
+ function truncateTrailingPayloads(messages, opts = {}) {
223
+ const toolCap = opts.toolCapChars ?? TOOL_CAP_CHARS
224
+ const failCap = opts.failedToolCapChars ?? FAILED_TOOL_CAP_CHARS
225
+ const textCap = opts.textCapChars ?? TEXT_CAP_CHARS
226
+
227
+ return (messages || []).map(m => {
228
+ if (m.role === 'tool') {
229
+ const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '')
230
+ const cap = isFailedToolContent(raw) ? failCap : toolCap
231
+ if (raw.length <= cap) return m
232
+ return { ...m, content: raw.slice(0, cap) + `\n... [truncated ${raw.length} chars]` }
233
+ }
234
+ if (m.role === 'user' || m.role === 'assistant') {
235
+ if (typeof m.content === 'string' && m.content.length > textCap) {
236
+ return { ...m, content: m.content.slice(0, textCap) + '\n... [truncated]' }
237
+ }
238
+ if (Array.isArray(m.content)) {
239
+ return {
240
+ ...m,
241
+ content: m.content.map(b => {
242
+ if (b?.type === 'tool_result') {
243
+ const raw = typeof b.content === 'string' ? b.content : JSON.stringify(b.content ?? '')
244
+ const cap = b.is_error ? failCap : toolCap
245
+ if (raw.length <= cap) return b
246
+ return { ...b, content: raw.slice(0, cap) + `\n... [truncated ${raw.length} chars]` }
247
+ }
248
+ if (b?.type === 'text' && typeof b.text === 'string' && b.text.length > textCap) {
249
+ return { ...b, text: b.text.slice(0, textCap) + '\n... [truncated]' }
250
+ }
251
+ return b
252
+ }),
253
+ }
254
+ }
255
+ }
256
+ return m
257
+ })
258
+ }
259
+
260
+ /**
261
+ * Hot-path: refresh System-1 state + retrieve evidence + compact messages.
262
+ * On any failure, returns windowed truncation of the original messages.
263
+ */
264
+ export async function buildJevSystem2Messages(db, rt, messages, opts = {}) {
265
+ const turns = opts.turns ?? 2
266
+ try {
267
+ const paths = extractRecentFilePaths(db, rt.sessionId)
268
+ const tail = transcriptTailText(db, rt.sessionId)
269
+ const layaOpts = opts.layaOpts || rt.layaOpts || {}
270
+
271
+ // Write-path: ingest latest tail as an observation (preserve; cheap when degraded)
272
+ if (opts.ingest !== false && tail) {
273
+ await ingestObservation(db, {
274
+ sessionId: rt.sessionId,
275
+ conversationId: rt.conversationId,
276
+ text: tail,
277
+ title: `turn:${Date.now()}`,
278
+ provenance: 'hot_path',
279
+ filePaths: paths,
280
+ layaOpts,
281
+ updateSessionState: true,
282
+ })
283
+ } else {
284
+ const extracted = await extractCanonicalState(tail, paths, layaOpts)
285
+ upsertLayaJevState(db, rt.sessionId, extracted)
286
+ }
287
+
288
+ const state = getLayaJevState(db, rt.sessionId)
289
+ const retrieved = await retrieveEvidence(db, opts.query || tail || 'continue', {
290
+ sessionId: rt.sessionId,
291
+ conversationId: rt.conversationId,
292
+ layaOpts,
293
+ })
294
+
295
+ return compactWithJev(messages, state, retrieved.evidence, { turns })
296
+ } catch {
297
+ // Resilience: rolling window fallback — never crash the CLI
298
+ return selectTrailingMessages(messages, { turns, includeLastFailedTool: true })
299
+ }
300
+ }
301
+
302
+ function extractText(payload) {
303
+ const blocks = payload.content
304
+ if (!Array.isArray(blocks)) return ''
305
+ return blocks.filter(b => b?.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n')
306
+ }