Files
orca/src/shared/jcode-session-files.ts
T
Neilandczzczz fe764404d2 fix(jcode): read the journal once per turn, key prompts by byte offset
The jcode prompt reader ran a synchronous bounded file scan plus a JSON
parse on every hook event. jcode blocks on pre_tool, so a turn that ran
four tools charged the user eight scans of latency it did not need — the
prompt cannot change inside a turn.

- Cache the journal read per pane, refreshed on the turn boundary that
  can change it. The cache holds the whole evidence record, since
  hasExplicitUserPrompt needs the transcript-evidence flag and not just
  the text, and it joins the existing pane-scoped lifecycle (close,
  rename, reset) rather than living in a module singleton.
- Key a journal prompt by its absolute byte offset instead of its
  region-local line index. The backward scan windows the file from EOF,
  so appending shifted every boundary and reminted the key for a prompt
  that never moved; a repeated turn_end then slipped past the same-hash
  dedupe as a second done event with duplicate telemetry.
- Pin the platform in the daemon pre-warm tests. The pre-warm is a no-op
  off POSIX, so the dedupe and retry cases would have passed vacuously
  on a Windows runner.

Co-authored-by: czzczz <chanzrz_zbf@foxmail.com>
2026-10-03 01:49:40 -07:00

220 lines
6.8 KiB
TypeScript

// Why: jcode hooks carry no user-prompt field, so the listener reads the last
// user message from jcode's on-disk session state: the live `.journal.jsonl`
// append log first (authoritative while the session is active), then the
// consolidated `session_*.json` document. Bounded like the Grok/Command Code
// transcript readers so hook events stay cheap on multi-megabyte sessions.
import { createHash } from 'node:crypto'
import { readFileSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { scanFileRegionsBackward } from './agent-hook-listener/reverse-file-region-scan'
const JCODE_SESSION_ID_MAX_LENGTH = 512
const JCODE_SESSION_SCAN_BYTES = 4 * 1024 * 1024
const JCODE_JOURNAL_CHUNK_BYTES = 64 * 1024
const JCODE_JSON_DOC_MAX_PARSE_BYTES = 8 * 1024 * 1024
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function resolveJcodeSessionsDir(
env: NodeJS.ProcessEnv = process.env,
homeDir: string = homedir()
): string {
const explicit = env.JCODE_HOME?.trim()
return explicit ? join(explicit, 'sessions') : join(homeDir, '.jcode', 'sessions')
}
function isSafeJcodeSessionId(value: unknown): value is string {
if (typeof value !== 'string') {
return false
}
const trimmed = value.trim()
if (trimmed.length === 0 || trimmed.length > JCODE_SESSION_ID_MAX_LENGTH) {
return false
}
// Why: session ids embed a timestamp and a hex suffix; reject anything with
// separators or control characters so no path traversal reaches the fs.
return /^[A-Za-z0-9_.:-]+$/.test(trimmed)
}
function sessionFilePath(sessionId: string): string {
return join(resolveJcodeSessionsDir(), `${sessionId}.json`)
}
function sessionJournalPath(sessionId: string): string {
return join(resolveJcodeSessionsDir(), `${sessionId}.journal.jsonl`)
}
/** Text of one jcode stored message: string content or `[{type:'text',text}]`. */
function messageText(message: Record<string, unknown>): string | null {
const content = message.content
if (typeof content === 'string') {
return content.trim().length > 0 ? content : null
}
if (!Array.isArray(content)) {
return null
}
const parts: string[] = []
for (const block of content) {
if (typeof block === 'string') {
parts.push(block)
continue
}
if (isRecord(block) && typeof block.text === 'string') {
parts.push(block.text)
}
}
const joined = parts.join(' ').replace(/\s+/g, ' ').trim()
return joined.length > 0 ? joined : null
}
/** True for jcode's injected session-context envelope (role user, display_role system). */
function isInjectedContextMessage(message: Record<string, unknown>): boolean {
return message.display_role === 'system' || message.role === 'system'
}
export type JcodeUserPromptEvidence = {
text: string
interactionKey: string
}
function buildInteractionKey(source: 'journal' | 'json', sessionId: string, salt: string): string {
return [
'jcode-transcript',
source,
createHash('sha256').update(sessionId).digest('hex').slice(0, 12),
createHash('sha256').update(salt).digest('hex').slice(0, 12)
].join('-')
}
/** Absolute byte offset of `lines[index]`. Why not the region-local index: the scan
* window slides as the journal grows, so the same record would key differently on a
* later read and a repeated turn_end would slip past the same-hash dedupe. */
function lineByteOffset(lines: readonly string[], index: number, regionPosition: number): number {
let offset = regionPosition
for (let i = 0; i < index; i += 1) {
offset += Buffer.byteLength(lines[i] ?? '', 'utf8') + 1
}
return offset
}
function readLastUserMessageFromJournalLines(
lines: readonly string[],
sessionId: string,
regionPosition: number
): JcodeUserPromptEvidence | null {
for (let index = lines.length - 1; index >= 0; index -= 1) {
let entry: unknown
try {
entry = JSON.parse(lines[index] ?? '')
} catch {
continue
}
if (!isRecord(entry)) {
continue
}
const appendMessages = entry.append_messages
if (!Array.isArray(appendMessages)) {
continue
}
for (let messageIndex = appendMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const record = appendMessages[messageIndex]
if (!isRecord(record)) {
continue
}
if (record.role !== 'user' || isInjectedContextMessage(record)) {
continue
}
const text = messageText(record)
if (!text || text.startsWith('<system-reminder>')) {
continue
}
return {
text,
interactionKey: buildInteractionKey(
'journal',
sessionId,
`${lineByteOffset(lines, index, regionPosition)}:${messageIndex}:${text}`
)
}
}
}
return null
}
function readLastUserMessageFromJournal(
journalPath: string,
sessionId: string
): JcodeUserPromptEvidence | null {
return (
scanFileRegionsBackward(
journalPath,
{ chunkBytes: JCODE_JOURNAL_CHUNK_BYTES, maxScanBytes: JCODE_SESSION_SCAN_BYTES },
(region, regionPosition) =>
readLastUserMessageFromJournalLines(
region.toString('utf8').split('\n'),
sessionId,
regionPosition
) ?? undefined
) ?? null
)
}
function readLastUserMessageFromJson(
jsonPath: string,
sessionId: string
): JcodeUserPromptEvidence | null {
let size = 0
try {
size = statSync(jsonPath).size
} catch {
return null
}
if (size <= 0 || size > JCODE_JSON_DOC_MAX_PARSE_BYTES) {
return null
}
try {
const parsed: unknown = JSON.parse(readFileSync(jsonPath, 'utf8'))
if (!isRecord(parsed)) {
return null
}
const messages = parsed.messages
if (!Array.isArray(messages)) {
return null
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const record = messages[index]
if (!isRecord(record)) {
continue
}
if (record.role !== 'user' || isInjectedContextMessage(record)) {
continue
}
const text = messageText(record)
if (!text || text.startsWith('<system-reminder>')) {
continue
}
return { text, interactionKey: buildInteractionKey('json', sessionId, `${index}:${text}`) }
}
return null
} catch {
return null
}
}
/** Last real user prompt for a jcode session, or null when none is recoverable. */
export function readLastJcodeUserPromptFromHookPayload(
hookPayload: Record<string, unknown>
): JcodeUserPromptEvidence | null {
const sessionId = hookPayload.session_id ?? hookPayload.sessionId
if (!isSafeJcodeSessionId(sessionId)) {
return null
}
return (
readLastUserMessageFromJournal(sessionJournalPath(sessionId), sessionId) ??
readLastUserMessageFromJson(sessionFilePath(sessionId), sessionId)
)
}