@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
|
@@ -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
|
+
}
|
package/lib/compact.mjs
ADDED
|
@@ -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
|
+
}
|