feat(observer): parser v2 — environment, task_size, prompt_signal extractors

This commit is contained in:
Дмитрий
2026-05-19 10:15:17 +03:00
parent 514d5d09b9
commit e101949c27
3 changed files with 171 additions and 6 deletions
+78 -3
View File
@@ -19,16 +19,19 @@ const SUPERPOWERS_PREFIX = 'superpowers:';
function parseLines(text) {
const entries = [];
let broken = 0;
let total = 0;
for (const line of String(text || '').split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
total += 1;
try {
entries.push(JSON.parse(trimmed));
} catch {
// broken line — skip, never throw
broken += 1; // broken line — counted for parse_gap, never thrown
}
}
return entries;
return { entries, broken, total };
}
// A genuine user prompt (turn boundary) — not a tool_result carrier message.
@@ -96,6 +99,78 @@ function collectToolUse(entries) {
return { skills, counts, errorCount };
}
const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
/**
* Deterministic environment factors for the turn that starts at turnStartIdx.
* economy_level / parallel_session are scanned from the stringified turn;
* model / post_compaction / session_turn from structural fields.
*/
export function extractEnvironment(allEntries, turnStartIdx) {
const turn = allEntries.slice(turnStartIdx);
const rawTurn = JSON.stringify(turn);
const econ = rawTurn.match(/=== ECONOMY MODE:\s*(\d+)\s*%/);
const economy_level = econ ? Number(econ[1]) : null;
let model = null;
for (const e of turn) {
if (e && e.message && e.message.model) {
model = e.message.model;
break;
}
}
let post_compaction = false;
for (let i = 0; i < turnStartIdx && i < allEntries.length; i++) {
if (allEntries[i] && allEntries[i].isCompactSummary === true) {
post_compaction = true;
break;
}
}
let session_turn = 0;
for (let i = 0; i <= turnStartIdx && i < allEntries.length; i++) {
if (isRealUserPrompt(allEntries[i])) session_turn += 1;
}
const parallel_session = /параллельн|parallel session|чужой staged|foreign git index/i.test(rawTurn);
return { economy_level, model, post_compaction, session_turn, parallel_session };
}
/** Task size: total tool calls + unique file paths touched (per spec §3, gap-resolution 2). */
export function extractTaskSize(turn) {
let tool_calls = 0;
const files = new Set();
for (const e of turn) {
const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
for (const b of content) {
if (b && b.type === 'tool_use') {
tool_calls += 1;
if (FILE_TOOLS.has(b.name) && b.input) {
const p = b.input.file_path || b.input.notebook_path;
if (p) files.add(String(p));
}
}
}
}
return { tool_calls, files_touched: files.size, files: [...files] };
}
/** Classify the opening user-prompt sentiment (per spec §6 / gap-resolution 1). */
export function classifyPromptSignal(text) {
const t = String(text || '').toLowerCase().trim();
if (/не то\b|не так\b|переделай|отбой|\bстоп\b|почему ты|неверно|не верно|это не /.test(t)) {
return 'correction';
}
if (/^(ок|окей|ok|спасибо|супер|отлично|готово|дальше|идеально)([,\s]|$)/.test(t)) {
return 'approval';
}
if (classifyTask(t) !== 'other' && t.length > 15) return 'new_task';
return 'neutral';
}
/**
* Parse a transcript JSONL string into observer episode fields.
* @param {string} transcriptText - Raw JSONL transcript contents.
@@ -103,7 +178,7 @@ function collectToolUse(entries) {
* @returns {object} Episode with 5 mandatory fields + events.
*/
export function parseTranscript(transcriptText, fallbackSessionId = null) {
const entries = parseLines(transcriptText);
const { entries } = parseLines(transcriptText);
const withSession = entries.find((e) => e && e.sessionId);
const sessionId =