Compare commits
2 Commits
v2.8.10-se
...
v2.8.12-ha
| Author | SHA1 | Date | |
|---|---|---|---|
| a7a40c5b46 | |||
| e5183cc71b |
@@ -30,6 +30,7 @@ import {
|
||||
type TerminalMessageStatus,
|
||||
} from './finalize-message.js';
|
||||
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
||||
import { emitHook } from '../plugins/host.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (
|
||||
@@ -123,6 +124,22 @@ export function createDispatcher(deps: Deps): {
|
||||
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
|
||||
}
|
||||
|
||||
// EmitHook: fire-and-forget turn.end notification. Best-effort — a hook throwing
|
||||
// is silently swallowed so it never blocks the dispatch flow.
|
||||
function emitTurnEnd(
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
state: string,
|
||||
agent?: string | null,
|
||||
model?: string | null,
|
||||
outputSummary?: string,
|
||||
): void {
|
||||
void emitHook('turn.end', {
|
||||
sessionId,
|
||||
turnSummary: { taskId, state, agent, model: model ?? undefined, outputSummary },
|
||||
});
|
||||
}
|
||||
|
||||
// F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal
|
||||
// state and publish the matching message_complete frame. Best-effort + idempotent
|
||||
// (the helper's `WHERE status='streaming'` guard) — a failure here must never mask
|
||||
@@ -318,6 +335,7 @@ export function createDispatcher(deps: Deps): {
|
||||
|
||||
// Declared before try so the catch block can write it back on the task row.
|
||||
let chatId: string | null = null;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
try {
|
||||
// Mark running
|
||||
@@ -330,7 +348,6 @@ export function createDispatcher(deps: Deps): {
|
||||
// Session setup: reuse a pre-created session (e.g. Q&A arena contestants
|
||||
// whose persona is stamped on the session via agent_id) or create a fresh one.
|
||||
const model = task.model ?? config.DEFAULT_MODEL;
|
||||
let sessionId: string;
|
||||
if (task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
} else {
|
||||
@@ -377,6 +394,7 @@ export function createDispatcher(deps: Deps): {
|
||||
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, 'cancelled', null, task.model);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,6 +417,7 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
||||
emitTurnEnd(sessionId, taskId, 'completed', null, task.model, summary);
|
||||
} else {
|
||||
const [msg] = await sql<{ content: string | null }[]>`
|
||||
SELECT content FROM messages WHERE id = ${assistantId}
|
||||
@@ -410,6 +429,7 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
||||
emitTurnEnd(sessionId, taskId, 'failed', null, task.model, summary);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -419,6 +439,7 @@ export function createDispatcher(deps: Deps): {
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, 'failed', null, task.model, errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +705,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
clearTaskCommands(taskId);
|
||||
return;
|
||||
@@ -738,6 +760,7 @@ export function createDispatcher(deps: Deps): {
|
||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||
// #10: external-agent turn completed cleanly.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
|
||||
emitTurnEnd(sessionId, taskId, 'completed', agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
|
||||
} catch (err) {
|
||||
@@ -762,6 +785,7 @@ export function createDispatcher(deps: Deps): {
|
||||
// preceded its assignment — guard so the status publish never masks the real
|
||||
// error.
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed');
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
|
||||
// Best-effort cleanup
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
@@ -1030,6 +1054,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1090,6 +1115,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1104,6 +1130,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -1308,6 +1335,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1367,6 +1395,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1381,6 +1410,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -1576,6 +1606,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1638,6 +1669,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1652,6 +1684,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { AgentEvent } from './agent-backend.js';
|
||||
import { type AcpToolSnapshot, snapshotToWireToolCall } from './acp-tool-snapshot.js';
|
||||
import { type AcpToolSnapshot, snapshotToWireToolCall, mapToolLifecycleStatus } from './acp-tool-snapshot.js';
|
||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||
import type { DcpStreamStripper } from './dcp-strip.js';
|
||||
import { emitHook } from '../plugins/host.js';
|
||||
|
||||
export interface FrameEmitterOpts {
|
||||
broker?: Broker;
|
||||
@@ -91,8 +92,29 @@ export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter {
|
||||
}
|
||||
break;
|
||||
case 'tool_call':
|
||||
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
|
||||
if (canStream()) {
|
||||
broker!.publishFrame(sessionId!, {
|
||||
type: 'tool_call',
|
||||
message_id: assistantId!,
|
||||
chat_id: chatId!,
|
||||
tool_call: snapshotToWireToolCall(e.toolCall),
|
||||
} as WsFrame);
|
||||
}
|
||||
break;
|
||||
case 'tool_update':
|
||||
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
|
||||
{
|
||||
const lifecycle = mapToolLifecycleStatus(e.toolCall.status, e.toolCall.rawOutput);
|
||||
if (lifecycle === 'completed' || lifecycle === 'failed') {
|
||||
void emitHook('tool.execute.after', {
|
||||
toolName: e.toolCall.title,
|
||||
args: e.toolCall.rawInput,
|
||||
result: e.toolCall.rawOutput,
|
||||
duration: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (canStream()) {
|
||||
broker!.publishFrame(sessionId!, {
|
||||
type: 'tool_call',
|
||||
|
||||
10
apps/coder/src/services/hashline/constants.ts
Normal file
10
apps/coder/src/services/hashline/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"
|
||||
|
||||
export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
|
||||
const high = i >>> 4
|
||||
const low = i & 0x0f
|
||||
return `${NIBBLE_STR[high]}${NIBBLE_STR[low]}`
|
||||
})
|
||||
|
||||
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
|
||||
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/
|
||||
31
apps/coder/src/services/hashline/hash-computation.ts
Normal file
31
apps/coder/src/services/hashline/hash-computation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { HASHLINE_DICT } from "./constants.js"
|
||||
import { hashXxh32 } from "./xxhash32.js"
|
||||
|
||||
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
|
||||
|
||||
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
|
||||
const stripped = normalizedContent
|
||||
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
|
||||
const hash = hashXxh32(stripped, seed)
|
||||
const index = hash % 256
|
||||
return HASHLINE_DICT[index]!
|
||||
}
|
||||
|
||||
export function computeLineHash(lineNumber: number, content: string): string {
|
||||
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trimEnd())
|
||||
}
|
||||
|
||||
export function computeLegacyLineHash(lineNumber: number, content: string): string {
|
||||
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").replace(/\s+/g, ""))
|
||||
}
|
||||
|
||||
export function formatHashLine(lineNumber: number, content: string): string {
|
||||
const hash = computeLineHash(lineNumber, content)
|
||||
return `${lineNumber}#${hash}|${content}`
|
||||
}
|
||||
|
||||
export function formatHashLines(content: string): string {
|
||||
if (!content) return ""
|
||||
const lines = content.split("\n")
|
||||
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n")
|
||||
}
|
||||
11
apps/coder/src/services/hashline/index.ts
Normal file
11
apps/coder/src/services/hashline/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Hashline editing core — content-hash anchors for edit_file stale-patch detection.
|
||||
*
|
||||
* Ported from oh-my-openagent/packages/hashline-core/.
|
||||
* Bundles a runtime-aware xxHash32 (Bun fast-path, pure-JS fallback).
|
||||
*/
|
||||
export { computeLineHash, formatHashLines, formatHashLine, computeLegacyLineHash } from "./hash-computation.js"
|
||||
export { parseLineRef, validateLineRef, validateLineRefs, HashlineMismatchError, normalizeLineRef } from "./validation.js"
|
||||
export type { LineRef } from "./validation.js"
|
||||
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants.js"
|
||||
export type { ReplaceEdit, AppendEdit, PrependEdit, HashlineEdit } from "./types.js"
|
||||
20
apps/coder/src/services/hashline/types.ts
Normal file
20
apps/coder/src/services/hashline/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface ReplaceEdit {
|
||||
op: "replace"
|
||||
pos: string
|
||||
end?: string
|
||||
lines: string | string[]
|
||||
}
|
||||
|
||||
export interface AppendEdit {
|
||||
op: "append"
|
||||
pos?: string
|
||||
lines: string | string[]
|
||||
}
|
||||
|
||||
export interface PrependEdit {
|
||||
op: "prepend"
|
||||
pos?: string
|
||||
lines: string | string[]
|
||||
}
|
||||
|
||||
export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit
|
||||
192
apps/coder/src/services/hashline/validation.ts
Normal file
192
apps/coder/src/services/hashline/validation.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { computeLegacyLineHash, computeLineHash } from "./hash-computation.js"
|
||||
import { HASHLINE_REF_PATTERN } from "./constants.js"
|
||||
|
||||
export interface LineRef {
|
||||
line: number
|
||||
hash: string
|
||||
}
|
||||
|
||||
interface HashMismatch {
|
||||
line: number
|
||||
expected: string
|
||||
}
|
||||
|
||||
const MISMATCH_CONTEXT = 2
|
||||
|
||||
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
|
||||
|
||||
function isCompatibleLineHash(line: number, content: string, hash: string): boolean {
|
||||
return computeLineHash(line, content) === hash || computeLegacyLineHash(line, content) === hash
|
||||
}
|
||||
|
||||
export function normalizeLineRef(ref: string): string {
|
||||
const originalTrimmed = ref.trim()
|
||||
let trimmed = originalTrimmed
|
||||
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
|
||||
trimmed = trimmed.replace(/\s*#\s*/, "#")
|
||||
trimmed = trimmed.replace(/\|.*$/, "")
|
||||
trimmed = trimmed.trim()
|
||||
|
||||
if (HASHLINE_REF_PATTERN.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
|
||||
if (extracted) {
|
||||
return extracted[1]!
|
||||
}
|
||||
|
||||
return originalTrimmed
|
||||
}
|
||||
|
||||
export function parseLineRef(ref: string): LineRef {
|
||||
const normalized = normalizeLineRef(ref)
|
||||
const match = normalized.match(HASHLINE_REF_PATTERN)
|
||||
if (match) {
|
||||
return {
|
||||
line: Number.parseInt(match[1]!, 10),
|
||||
hash: match[2]!,
|
||||
}
|
||||
}
|
||||
const hashIdx = normalized.indexOf('#')
|
||||
if (hashIdx > 0) {
|
||||
const prefix = normalized.slice(0, hashIdx)
|
||||
const suffix = normalized.slice(hashIdx + 1)
|
||||
if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) {
|
||||
throw new Error(
|
||||
`Invalid line reference: "${ref}". "${prefix}" is not a line number. ` +
|
||||
`Use the actual line number from the read output.`
|
||||
)
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
|
||||
)
|
||||
}
|
||||
|
||||
export function validateLineRef(lines: string[], ref: string): void {
|
||||
const { line, hash } = parseLineRefWithHint(ref, lines)
|
||||
|
||||
if (line < 1 || line > lines.length) {
|
||||
throw new Error(
|
||||
`Line number ${line} out of bounds. File has ${lines.length} lines.`
|
||||
)
|
||||
}
|
||||
|
||||
const content = lines[line - 1]
|
||||
if (content === undefined) {
|
||||
throw new Error(
|
||||
`Line number ${line} out of bounds. File has ${lines.length} lines.`
|
||||
)
|
||||
}
|
||||
if (!isCompatibleLineHash(line, content, hash)) {
|
||||
throw new HashlineMismatchError([{ line, expected: hash }], lines)
|
||||
}
|
||||
}
|
||||
|
||||
export class HashlineMismatchError extends Error {
|
||||
readonly remaps: ReadonlyMap<string, string>
|
||||
|
||||
constructor(
|
||||
private readonly mismatches: HashMismatch[],
|
||||
private readonly fileLines: string[]
|
||||
) {
|
||||
super(HashlineMismatchError.formatMessage(mismatches, fileLines))
|
||||
this.name = "HashlineMismatchError"
|
||||
const remaps = new Map<string, string>()
|
||||
for (const mismatch of mismatches) {
|
||||
const content = fileLines[mismatch.line - 1]
|
||||
const actualLine = content ?? ""
|
||||
const actual = computeLineHash(mismatch.line, actualLine)
|
||||
remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`)
|
||||
}
|
||||
this.remaps = remaps
|
||||
}
|
||||
|
||||
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
|
||||
const mismatchByLine = new Map<number, HashMismatch>()
|
||||
for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch)
|
||||
|
||||
const displayLines = new Set<number>()
|
||||
for (const mismatch of mismatches) {
|
||||
const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT)
|
||||
const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT)
|
||||
for (let line = low; line <= high; line++) displayLines.add(line)
|
||||
}
|
||||
|
||||
const sortedLines = [...displayLines].sort((a, b) => a - b)
|
||||
const output: string[] = []
|
||||
output.push(
|
||||
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
|
||||
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
|
||||
)
|
||||
output.push("")
|
||||
|
||||
let previousLine = -1
|
||||
for (const line of sortedLines) {
|
||||
if (previousLine !== -1 && line > previousLine + 1) {
|
||||
output.push(" ...")
|
||||
}
|
||||
previousLine = line
|
||||
|
||||
const content = fileLines[line - 1] ?? ""
|
||||
const hash = computeLineHash(line, content)
|
||||
const prefix = `${line}#${hash}|${content}`
|
||||
if (mismatchByLine.has(line)) {
|
||||
output.push(`>>> ${prefix}`)
|
||||
} else {
|
||||
output.push(` ${prefix}`)
|
||||
}
|
||||
}
|
||||
|
||||
return output.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
function suggestLineForHash(ref: string, lines: string[]): string | null {
|
||||
const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/)
|
||||
if (!hashMatch) return null
|
||||
const hash = hashMatch[1]!
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (isCompatibleLineHash(i + 1, lines[i] ?? "", hash)) {
|
||||
return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i] ?? "")}"?`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseLineRefWithHint(ref: string, lines: string[]): LineRef {
|
||||
try {
|
||||
return parseLineRef(ref)
|
||||
} catch (parseError) {
|
||||
const hint = suggestLineForHash(ref, lines)
|
||||
if (hint && parseError instanceof Error) {
|
||||
throw new Error(`${parseError.message} ${hint}`)
|
||||
}
|
||||
throw parseError
|
||||
}
|
||||
}
|
||||
|
||||
export function validateLineRefs(lines: string[], refs: string[]): void {
|
||||
const mismatches: HashMismatch[] = []
|
||||
|
||||
for (const ref of refs) {
|
||||
const { line, hash } = parseLineRefWithHint(ref, lines)
|
||||
|
||||
if (line < 1 || line > lines.length) {
|
||||
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
|
||||
}
|
||||
|
||||
const content = lines[line - 1]
|
||||
if (content === undefined) {
|
||||
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
|
||||
}
|
||||
if (!isCompatibleLineHash(line, content, hash)) {
|
||||
mismatches.push({ line, expected: hash })
|
||||
}
|
||||
}
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
throw new HashlineMismatchError(mismatches, lines)
|
||||
}
|
||||
}
|
||||
90
apps/coder/src/services/hashline/xxhash32.ts
Normal file
90
apps/coder/src/services/hashline/xxhash32.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } }
|
||||
|
||||
const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime }
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const PRIME32_1 = 0x9e3779b1
|
||||
const PRIME32_2 = 0x85ebca77
|
||||
const PRIME32_3 = 0xc2b2ae3d
|
||||
const PRIME32_4 = 0x27d4eb2f
|
||||
const PRIME32_5 = 0x165667b1
|
||||
|
||||
function rotateLeft32(value: number, bits: number): number {
|
||||
return ((value << bits) | (value >>> (32 - bits))) >>> 0
|
||||
}
|
||||
|
||||
function readUint32LittleEndian(input: Uint8Array, offset: number): number {
|
||||
return (
|
||||
((input[offset] ?? 0) |
|
||||
((input[offset + 1] ?? 0) << 8) |
|
||||
((input[offset + 2] ?? 0) << 16) |
|
||||
((input[offset + 3] ?? 0) << 24)) >>>
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
function round32(accumulator: number, value: number): number {
|
||||
const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0
|
||||
return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0
|
||||
}
|
||||
|
||||
function xxHash32Js(input: Uint8Array, seed: number): number {
|
||||
let offset = 0
|
||||
const length = input.length
|
||||
let hash: number
|
||||
|
||||
if (length >= 16) {
|
||||
const limit = length - 16
|
||||
let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0
|
||||
let value2 = (seed + PRIME32_2) >>> 0
|
||||
let value3 = seed >>> 0
|
||||
let value4 = (seed - PRIME32_1) >>> 0
|
||||
|
||||
while (offset <= limit) {
|
||||
value1 = round32(value1, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
value2 = round32(value2, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
value3 = round32(value3, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
value4 = round32(value4, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
}
|
||||
|
||||
hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0
|
||||
hash = (hash + rotateLeft32(value3, 12)) >>> 0
|
||||
hash = (hash + rotateLeft32(value4, 18)) >>> 0
|
||||
} else {
|
||||
hash = (seed + PRIME32_5) >>> 0
|
||||
}
|
||||
|
||||
hash = (hash + length) >>> 0
|
||||
|
||||
while (offset + 4 <= length) {
|
||||
hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0
|
||||
hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0
|
||||
offset += 4
|
||||
}
|
||||
|
||||
while (offset < length) {
|
||||
hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0
|
||||
hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0
|
||||
offset += 1
|
||||
}
|
||||
|
||||
hash = (hash ^ (hash >>> 15)) >>> 0
|
||||
hash = Math.imul(hash, PRIME32_2) >>> 0
|
||||
hash = (hash ^ (hash >>> 13)) >>> 0
|
||||
hash = Math.imul(hash, PRIME32_3) >>> 0
|
||||
|
||||
return (hash ^ (hash >>> 16)) >>> 0
|
||||
}
|
||||
|
||||
export function hashXxh32(input: string, seed: number): number {
|
||||
const bun = runtime.Bun
|
||||
if (bun !== undefined) {
|
||||
return bun.hash.xxHash32(input, seed)
|
||||
}
|
||||
|
||||
return xxHash32Js(encoder.encode(input), seed >>> 0)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
|
||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||
---
|
||||
You review code. Find real problems, not style nits.
|
||||
@@ -56,7 +56,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||
---
|
||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||
@@ -82,7 +82,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 5
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||
---
|
||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||
@@ -125,7 +125,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 1.5
|
||||
steps: 20
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes, web_fetch, web_search]
|
||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||
---
|
||||
You design. You produce build plans, not code.
|
||||
@@ -167,7 +167,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
|
||||
description: Audits code for security vulnerabilities. Read-only.
|
||||
---
|
||||
You audit for security issues. Concrete findings only, no generic warnings.
|
||||
@@ -212,7 +212,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [view_file, list_dir, grep, find_files]
|
||||
tools: [find_files, get_codebase_overview, grep, list_dir, view_file]
|
||||
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
|
||||
---
|
||||
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
|
||||
@@ -250,7 +250,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
||||
---
|
||||
You map codebases. Start broad, then drill into specifics.
|
||||
@@ -278,7 +278,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 10
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, watch_changes]
|
||||
description: Produces actionable step plans from requirements. Read-only — never modifies files.
|
||||
---
|
||||
You produce actionable step plans. You do not modify files.
|
||||
|
||||
Reference in New Issue
Block a user