@hicaru/pi-rlm 0.1.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/README.md +237 -0
- package/README.ru.md +200 -0
- package/README.zh-CN.md +224 -0
- package/package.json +54 -0
- package/src/bridge/fallback-todo.ts +137 -0
- package/src/bridge/interactive.ts +65 -0
- package/src/bridge/llm-query.ts +124 -0
- package/src/bridge/model.ts +97 -0
- package/src/bridge/pi-interactive.ts +86 -0
- package/src/bridge/rlm-query.ts +78 -0
- package/src/commands/rlm-config.ts +42 -0
- package/src/commands/rlm.ts +165 -0
- package/src/config/defaults.ts +38 -0
- package/src/config/settings.ts +185 -0
- package/src/context/repomix-context.ts +253 -0
- package/src/core/answer.ts +97 -0
- package/src/core/compaction.ts +64 -0
- package/src/core/engine.ts +408 -0
- package/src/core/history.ts +13 -0
- package/src/core/iteration.ts +45 -0
- package/src/core/limits.ts +90 -0
- package/src/core/pipeline.ts +100 -0
- package/src/core/resource-limits.ts +14 -0
- package/src/core/types.ts +131 -0
- package/src/index.ts +165 -0
- package/src/mode/input-router.ts +23 -0
- package/src/mode/rlm-mode.ts +149 -0
- package/src/patch/apply.ts +148 -0
- package/src/patch/index.ts +37 -0
- package/src/prompts/system.ts +278 -0
- package/src/prompts/user.ts +21 -0
- package/src/sandbox/protocol.ts +191 -0
- package/src/sandbox/sandbox-manager.ts +143 -0
- package/src/sandbox/sandbox.ts +362 -0
- package/src/sandbox/worker.py +457 -0
- package/src/state/events.ts +22 -0
- package/src/state/index.ts +23 -0
- package/src/state/internal.ts +46 -0
- package/src/state/paths.ts +42 -0
- package/src/state/reads.ts +96 -0
- package/src/state/resume.ts +154 -0
- package/src/state/rows.ts +117 -0
- package/src/state/writes.ts +56 -0
- package/src/telemetry/dispatcher.ts +116 -0
- package/src/telemetry/index.ts +14 -0
- package/src/telemetry/mlflow-config.ts +15 -0
- package/src/telemetry/mlflow-sink.ts +136 -0
- package/src/telemetry/mlflow.ts +99 -0
- package/src/telemetry/sink.ts +8 -0
- package/src/text/edits.ts +16 -0
- package/src/text/parsing.ts +35 -0
- package/src/text/preview.ts +18 -0
- package/src/text/tokens.ts +64 -0
- package/src/tool/apply-diff-tool.ts +125 -0
- package/src/tool/emitter-listener.ts +24 -0
- package/src/tool/repl-details.ts +23 -0
- package/src/tool/repl-tool.ts +528 -0
- package/src/tool/rlm-aggregator.ts +115 -0
- package/src/tool/rlm-details.ts +53 -0
- package/src/tool/rlm-events.ts +215 -0
- package/src/tool/rlm-tool.ts +199 -0
- package/src/tool/subcall-render.ts +129 -0
- package/src/tool/subcall-store.ts +90 -0
- package/src/tool/tool-utils.ts +73 -0
- package/src/ui/config-panel.ts +92 -0
- package/src/ui/intro.ts +23 -0
- package/src/ui/model-picker.ts +139 -0
- package/src/ui/status.ts +26 -0
- package/src/ui/theme.ts +47 -0
- package/src/util/concurrency.ts +15 -0
- package/src/util/errors.ts +27 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { SubcallKind } from "../tool/rlm-details.ts";
|
|
2
|
+
import type { SubcallStart } from "../state/events.ts";
|
|
3
|
+
import { Dispatcher, type DispatcherSink } from "./dispatcher.ts";
|
|
4
|
+
import {
|
|
5
|
+
MlflowTracer,
|
|
6
|
+
msToNs,
|
|
7
|
+
SpanStatusCode,
|
|
8
|
+
SpanType,
|
|
9
|
+
type MlflowConfig,
|
|
10
|
+
type SpanTracer,
|
|
11
|
+
} from "./mlflow.ts";
|
|
12
|
+
import type { TelemetrySink } from "./sink.ts";
|
|
13
|
+
|
|
14
|
+
// @mlflow/core keeps process-global trace state. RLM telemetry uses one shared
|
|
15
|
+
// tracer per process and should not be combined with another MLflow init path in
|
|
16
|
+
// the same host process.
|
|
17
|
+
let sharedTracer: MlflowTracer | undefined;
|
|
18
|
+
function tracerFor(config: MlflowConfig): MlflowTracer {
|
|
19
|
+
sharedTracer ??= new MlflowTracer(config);
|
|
20
|
+
return sharedTracer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SPAN_TYPE: Readonly<Record<SubcallKind, SpanType>> = Object.freeze({
|
|
24
|
+
root: SpanType.AGENT,
|
|
25
|
+
rlm: SpanType.AGENT,
|
|
26
|
+
llm: SpanType.CHAT_MODEL,
|
|
27
|
+
batch: SpanType.CHAT_MODEL,
|
|
28
|
+
tool: SpanType.TOOL,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
type SpanOp =
|
|
32
|
+
| { readonly kind: "start"; readonly id: string; readonly info: SubcallStart; readonly tsMs: number }
|
|
33
|
+
| { readonly kind: "usage"; readonly id: string; readonly costUsd: number; readonly tokens: number }
|
|
34
|
+
| { readonly kind: "end"; readonly id: string; readonly error?: string; readonly resultPreview?: string; readonly tsMs: number };
|
|
35
|
+
|
|
36
|
+
class SpanApplier<S> implements DispatcherSink<SpanOp> {
|
|
37
|
+
readonly name = "rlm-mlflow";
|
|
38
|
+
private readonly spans = new Map<string, S>();
|
|
39
|
+
private readonly usage = new Map<string, { cost: number; tokens: number }>();
|
|
40
|
+
|
|
41
|
+
constructor(private readonly tracer: SpanTracer<S>) {}
|
|
42
|
+
|
|
43
|
+
async handle(op: SpanOp): Promise<void> {
|
|
44
|
+
if (op.kind === "start") {
|
|
45
|
+
const parent = op.info.parentId ? this.spans.get(op.info.parentId) : undefined;
|
|
46
|
+
const span = this.tracer.startSpan({
|
|
47
|
+
name: op.info.label,
|
|
48
|
+
spanType: SPAN_TYPE[op.info.kind],
|
|
49
|
+
parent,
|
|
50
|
+
inputs: {
|
|
51
|
+
...(op.info.model ? { model: op.info.model } : {}),
|
|
52
|
+
...(op.info.detail ? { detail: op.info.detail } : {}),
|
|
53
|
+
},
|
|
54
|
+
attributes: {
|
|
55
|
+
"rlm.kind": op.info.kind,
|
|
56
|
+
"rlm.depth": op.info.depth,
|
|
57
|
+
...(op.info.model ? { "rlm.model": op.info.model } : {}),
|
|
58
|
+
...(op.info.runId ? { "rlm.runId": op.info.runId } : {}),
|
|
59
|
+
...(op.info.resume ? { "rlm.resume": true } : {}),
|
|
60
|
+
},
|
|
61
|
+
startTimeNs: msToNs(op.tsMs),
|
|
62
|
+
});
|
|
63
|
+
if (span !== undefined) this.spans.set(op.id, span);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (op.kind === "usage") {
|
|
68
|
+
const usage = this.usage.get(op.id) ?? { cost: 0, tokens: 0 };
|
|
69
|
+
this.usage.set(op.id, { cost: usage.cost + op.costUsd, tokens: usage.tokens + op.tokens });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.closeSpan(op.id, {
|
|
74
|
+
outputs: op.resultPreview ? { result: op.resultPreview } : undefined,
|
|
75
|
+
status: op.error ? SpanStatusCode.ERROR : undefined,
|
|
76
|
+
endTimeNs: msToNs(op.tsMs),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async flush(): Promise<void> {
|
|
81
|
+
await this.tracer.flush();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async shutdown(): Promise<void> {
|
|
85
|
+
for (const id of [...this.spans.keys()]) this.closeSpan(id, { status: SpanStatusCode.ERROR });
|
|
86
|
+
await this.tracer.flush();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private closeSpan(
|
|
90
|
+
id: string,
|
|
91
|
+
opts: { readonly outputs?: Readonly<Record<string, unknown>>; readonly status?: SpanStatusCode; readonly endTimeNs?: number },
|
|
92
|
+
): void {
|
|
93
|
+
const span = this.spans.get(id);
|
|
94
|
+
if (span === undefined) return;
|
|
95
|
+
const usage = this.usage.get(id);
|
|
96
|
+
this.tracer.endSpan(span, {
|
|
97
|
+
...(usage ? { attributes: { "rlm.usage.cost_usd": usage.cost, "rlm.usage.tokens": usage.tokens } } : {}),
|
|
98
|
+
...opts,
|
|
99
|
+
});
|
|
100
|
+
this.spans.delete(id);
|
|
101
|
+
this.usage.delete(id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class MlflowSink implements TelemetrySink {
|
|
106
|
+
private readonly dispatcher: Dispatcher<SpanOp>;
|
|
107
|
+
|
|
108
|
+
constructor(config: MlflowConfig, maxQueueSize = 100, tracer: SpanTracer = tracerFor(config)) {
|
|
109
|
+
this.dispatcher = new Dispatcher<SpanOp>({ maxQueueSize });
|
|
110
|
+
this.dispatcher.registerSink(new SpanApplier(tracer));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
start(id: string, info: SubcallStart): void {
|
|
114
|
+
if (id) this.dispatcher.dispatch({ kind: "start", id, info, tsMs: Date.now() });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
usage(id: string, costUsd: number, tokens: number): void {
|
|
118
|
+
if (id) this.dispatcher.dispatch({ kind: "usage", id, costUsd, tokens });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
end(id: string, opts?: { readonly error?: string; readonly resultPreview?: string }): void {
|
|
122
|
+
if (id) {
|
|
123
|
+
this.dispatcher.dispatch({
|
|
124
|
+
kind: "end",
|
|
125
|
+
id,
|
|
126
|
+
error: opts?.error,
|
|
127
|
+
resultPreview: opts?.resultPreview,
|
|
128
|
+
tsMs: Date.now(),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
shutdown(): Promise<void> {
|
|
134
|
+
return this.dispatcher.shutdown();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { flushTraces, init, type LiveSpan, startSpan as mlflowStartSpan, SpanStatusCode, SpanType } from "@mlflow/core";
|
|
2
|
+
import { resolveMlflowConfig, type MlflowConfig } from "./mlflow-config.ts";
|
|
3
|
+
|
|
4
|
+
export { type LiveSpan, type MlflowConfig, SpanStatusCode, SpanType };
|
|
5
|
+
|
|
6
|
+
export function msToNs(ms: number): number {
|
|
7
|
+
return ms * 1_000_000;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface StartSpanOptions<S> {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly spanType: SpanType;
|
|
13
|
+
readonly parent?: S;
|
|
14
|
+
readonly inputs?: Readonly<Record<string, unknown>>;
|
|
15
|
+
readonly attributes?: Readonly<Record<string, unknown>>;
|
|
16
|
+
readonly startTimeNs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EndSpanOptions {
|
|
20
|
+
readonly attributes?: Readonly<Record<string, unknown>>;
|
|
21
|
+
readonly outputs?: Readonly<Record<string, unknown>>;
|
|
22
|
+
readonly status?: SpanStatusCode;
|
|
23
|
+
readonly endTimeNs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SpanTracer<S = unknown> {
|
|
27
|
+
startSpan(options: StartSpanOptions<S>): S | undefined;
|
|
28
|
+
endSpan(span: S, options?: EndSpanOptions): void;
|
|
29
|
+
flush(): Promise<void>;
|
|
30
|
+
shutdown(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class MlflowTracer implements SpanTracer<LiveSpan> {
|
|
34
|
+
private initialized = false;
|
|
35
|
+
private initAttempted = false;
|
|
36
|
+
|
|
37
|
+
constructor(private readonly config: MlflowConfig) {}
|
|
38
|
+
|
|
39
|
+
startSpan(options: StartSpanOptions<LiveSpan>): LiveSpan | undefined {
|
|
40
|
+
if (!this.ensureInit()) return undefined;
|
|
41
|
+
const span = mlflowStartSpan({
|
|
42
|
+
name: options.name,
|
|
43
|
+
spanType: options.spanType,
|
|
44
|
+
...(options.parent ? { parent: options.parent } : {}),
|
|
45
|
+
...(options.inputs ? { inputs: options.inputs } : {}),
|
|
46
|
+
...(options.startTimeNs !== undefined ? { startTimeNs: options.startTimeNs } : {}),
|
|
47
|
+
});
|
|
48
|
+
if (options.attributes) {
|
|
49
|
+
for (const [key, value] of Object.entries(options.attributes)) span.setAttribute(key, value);
|
|
50
|
+
}
|
|
51
|
+
return span;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
endSpan(span: LiveSpan, options: EndSpanOptions = {}): void {
|
|
55
|
+
if (options.attributes) {
|
|
56
|
+
for (const [key, value] of Object.entries(options.attributes)) span.setAttribute(key, value);
|
|
57
|
+
}
|
|
58
|
+
span.end({
|
|
59
|
+
...(options.outputs ? { outputs: options.outputs } : {}),
|
|
60
|
+
...(options.status !== undefined ? { status: options.status } : {}),
|
|
61
|
+
...(options.endTimeNs !== undefined ? { endTimeNs: options.endTimeNs } : {}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async flush(): Promise<void> {
|
|
66
|
+
if (!this.initialized) return;
|
|
67
|
+
try {
|
|
68
|
+
await flushTraces();
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.warn(`[rlm-telemetry] mlflow flush failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async shutdown(): Promise<void> {
|
|
75
|
+
await this.flush();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private ensureInit(): boolean {
|
|
79
|
+
if (this.initialized || this.initAttempted) return this.initialized;
|
|
80
|
+
this.initAttempted = true;
|
|
81
|
+
const resolved = resolveMlflowConfig(this.config);
|
|
82
|
+
if (!resolved.trackingUri) {
|
|
83
|
+
console.warn("[rlm-telemetry] MLFLOW_TRACKING_URI unset — spans dropped");
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
init({
|
|
89
|
+
trackingUri: resolved.trackingUri,
|
|
90
|
+
experimentId: resolved.experimentId ?? "0",
|
|
91
|
+
...(resolved.trackingToken ? { trackingServerToken: resolved.trackingToken } : {}),
|
|
92
|
+
});
|
|
93
|
+
this.initialized = true;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.warn(`[rlm-telemetry] mlflow init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
96
|
+
}
|
|
97
|
+
return this.initialized;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SubcallStart } from "../state/events.ts";
|
|
2
|
+
|
|
3
|
+
export interface TelemetrySink {
|
|
4
|
+
start(id: string, info: SubcallStart): void;
|
|
5
|
+
usage(id: string, costUsd: number, tokens: number): void;
|
|
6
|
+
end(id: string, opts?: { readonly error?: string; readonly resultPreview?: string }): void;
|
|
7
|
+
shutdown(): Promise<void>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface AnchorEdit {
|
|
2
|
+
readonly oldText: string;
|
|
3
|
+
readonly newText: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function countOccurrences(haystack: string, needle: string): number {
|
|
7
|
+
if (needle.length === 0) return 0;
|
|
8
|
+
let count = 0;
|
|
9
|
+
let offset = 0;
|
|
10
|
+
for (;;) {
|
|
11
|
+
const match = haystack.indexOf(needle, offset);
|
|
12
|
+
if (match < 0) return count;
|
|
13
|
+
count++;
|
|
14
|
+
offset = match + needle.length;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing helpers: extract ```repl``` code blocks from a model response.
|
|
3
|
+
*
|
|
4
|
+
* The RLM root model emits Python wrapped in fenced blocks tagged `repl`. We extract those
|
|
5
|
+
* blocks in order; everything else is prose the model uses to think out loud.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const FENCE = /```[ \t]*repl[ \t]*\r?\n([\s\S]*?)```/g;
|
|
9
|
+
|
|
10
|
+
/** Return every ```repl``` block body, in document order. */
|
|
11
|
+
export function findReplBlocks(text: string): string[] {
|
|
12
|
+
const blocks: string[] = [];
|
|
13
|
+
let m: RegExpExecArray | null;
|
|
14
|
+
FENCE.lastIndex = 0;
|
|
15
|
+
while ((m = FENCE.exec(text)) !== null) {
|
|
16
|
+
const code = m[1] ?? "";
|
|
17
|
+
if (code.trim()) blocks.push(code.replace(/\s+$/, ""));
|
|
18
|
+
}
|
|
19
|
+
return blocks;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True if the response contains at least one runnable ```repl``` block. */
|
|
23
|
+
export function hasReplBlock(text: string): boolean {
|
|
24
|
+
FENCE.lastIndex = 0;
|
|
25
|
+
return FENCE.test(text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Truncate REPL stdout for the model's context window (head + tail, with an elision note). */
|
|
29
|
+
export function truncateOutput(text: string, limit = 20_000): string {
|
|
30
|
+
if (text.length <= limit) return text;
|
|
31
|
+
const head = Math.floor(limit * 0.7);
|
|
32
|
+
const tail = limit - head;
|
|
33
|
+
const cut = text.length - head - tail;
|
|
34
|
+
return `${text.slice(0, head)}\n... [${cut} chars elided] ...\n${text.slice(-tail)}`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Shared short text previews for live tree rows. */
|
|
2
|
+
|
|
3
|
+
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PREVIEW_CHARS = 200;
|
|
6
|
+
|
|
7
|
+
export function previewText(text: string, maxChars = DEFAULT_PREVIEW_CHARS): string {
|
|
8
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
9
|
+
return normalized.length > maxChars ? `${normalized.slice(0, Math.max(0, maxChars - 1))}…` : normalized;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function previewStdout(results: readonly ReplResult[]): string {
|
|
13
|
+
for (let index = results.length - 1; index >= 0; index--) {
|
|
14
|
+
const stdout = results[index]?.stdout.trim();
|
|
15
|
+
if (stdout) return previewText(stdout);
|
|
16
|
+
}
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight, dependency-free token estimation.
|
|
3
|
+
*
|
|
4
|
+
* We deliberately avoid a tokenizer dependency: RLM only needs rough budgets to decide when
|
|
5
|
+
* to chunk or compact, and a ~4-chars/token heuristic is accurate enough for that. Real token
|
|
6
|
+
* accounting comes back from the provider in `usage` after each call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const CHARS_PER_TOKEN = 4;
|
|
10
|
+
|
|
11
|
+
/** Rough token count for a list of role/content messages. */
|
|
12
|
+
export function estimateMessageTokens(messages: { content: string }[]): number {
|
|
13
|
+
let chars = 0;
|
|
14
|
+
for (const m of messages) chars += m.content.length + 8; // small per-message overhead
|
|
15
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Total character length of a context payload (string or list of strings). */
|
|
19
|
+
export function contextLength(context: unknown): number {
|
|
20
|
+
if (typeof context === "string") return context.length;
|
|
21
|
+
if (Array.isArray(context)) return context.reduce<number>((n, x) => n + String(x).length, 0);
|
|
22
|
+
return JSON.stringify(context ?? "").length;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Human label for a context payload's type, used in the metadata prompt. */
|
|
26
|
+
export function contextTypeLabel(context: unknown): string {
|
|
27
|
+
if (typeof context === "string") return "str";
|
|
28
|
+
if (Array.isArray(context)) return `list[${context.length}]`;
|
|
29
|
+
return typeof context;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Compact per-file token distribution for a bundle context (the article's `context_lengths`). */
|
|
33
|
+
export interface ContextSizeStats {
|
|
34
|
+
readonly files: number;
|
|
35
|
+
readonly min: number;
|
|
36
|
+
readonly median: number;
|
|
37
|
+
readonly max: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `true` if `v` is a context entry carrying an estimated `tokens` count. */
|
|
41
|
+
const isTokenizedEntry = (v: unknown): v is { readonly tokens: number } =>
|
|
42
|
+
typeof v === "object" && v !== null && typeof (v as { readonly tokens?: unknown }).tokens === "number";
|
|
43
|
+
|
|
44
|
+
/** Per-file token distribution for a context payload; `undefined` for plain strings or empty arrays.
|
|
45
|
+
* Handles both serialized ContextFile[] (flat array from serializeForSandbox) and raw ContextBundle
|
|
46
|
+
* objects ({ files: [...] }) so callers don't need to know which form they received. */
|
|
47
|
+
export function contextSizeStats(context: unknown): ContextSizeStats | undefined {
|
|
48
|
+
// Normalise to a flat entry list: accept either a direct array or an object with a .files array.
|
|
49
|
+
const entries: readonly unknown[] = Array.isArray(context)
|
|
50
|
+
? context
|
|
51
|
+
: Array.isArray((context as { readonly files?: unknown } | null)?.files)
|
|
52
|
+
? (context as { readonly files: readonly unknown[] }).files
|
|
53
|
+
: [];
|
|
54
|
+
if (entries.length === 0) return undefined;
|
|
55
|
+
const sizes = new Array<number>(entries.length);
|
|
56
|
+
for (let i = 0; i < entries.length; i++) {
|
|
57
|
+
const entry = entries[i];
|
|
58
|
+
sizes[i] = isTokenizedEntry(entry) ? entry.tokens : 0;
|
|
59
|
+
}
|
|
60
|
+
sizes.sort((a, b) => a - b);
|
|
61
|
+
const mid = sizes.length >> 1;
|
|
62
|
+
const median = sizes.length % 2 !== 0 ? sizes[mid] : Math.round((sizes[mid - 1] + sizes[mid]) / 2);
|
|
63
|
+
return Object.freeze<ContextSizeStats>({ files: sizes.length, min: sizes[0], median, max: sizes[sizes.length - 1] });
|
|
64
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `apply_diff` — the main-agent-facing editing tool.
|
|
3
|
+
*
|
|
4
|
+
* Lightweight by design: the root model produces a complete unified diff
|
|
5
|
+
* directly and this tool validates its header then writes to disk via `applyEdits()`.
|
|
6
|
+
* Unlike the old `propose_edits`,
|
|
7
|
+
* there is NO inner RLM engine turn (no generate→validate→revise loop) — the
|
|
8
|
+
* model is trusted to emit a correct diff.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
13
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { applyEdits } from "../patch/index.ts";
|
|
15
|
+
import { validateToolParams } from "./tool-utils.ts";
|
|
16
|
+
import { errorMessage } from "../util/errors.ts";
|
|
17
|
+
import { headlineStatusGlyph } from "./subcall-render.ts";
|
|
18
|
+
import type { ProposedDiffEdit } from "../sandbox/protocol.ts";
|
|
19
|
+
|
|
20
|
+
// ── Parameter schema ──────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const ApplyDiffParams = Object.freeze(Type.Object({
|
|
23
|
+
diff: Type.String({
|
|
24
|
+
description: "Full unified diff string. Must include --- a/<path> / +++ b/<path> header and @@ hunk markers.",
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// ── Details (no inner engine → no sub-call tree) ──────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface ApplyDiffDetails {
|
|
31
|
+
readonly status: "done" | "error";
|
|
32
|
+
readonly path: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const errorDetails = (): ApplyDiffDetails => ({ status: "error", path: "" });
|
|
36
|
+
|
|
37
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** True when the diff carries a real `--- a/<path>` header (not a bare `---`). */
|
|
40
|
+
function hasValidHeader(diff: string): boolean {
|
|
41
|
+
return /^--- \S/m.test(diff);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extract the first target file path from a `--- a/<path>` header line. */
|
|
45
|
+
function firstDiffPath(diff: string): string {
|
|
46
|
+
const match = /^--- a\/(.+)$/m.exec(diff);
|
|
47
|
+
return match?.[1] ?? "(unknown path)";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** First ~60 chars of the diff body, starting at the first hunk marker. */
|
|
51
|
+
function bodyPreview(diff: string): string {
|
|
52
|
+
const start = diff.indexOf("@@");
|
|
53
|
+
const body = start >= 0 ? diff.slice(start) : diff;
|
|
54
|
+
return body.replace(/\n/g, " ").slice(0, 60);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Tool factory ──────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export function createApplyDiffTool(): ToolDefinition<typeof ApplyDiffParams, ApplyDiffDetails> {
|
|
60
|
+
return {
|
|
61
|
+
name: "apply_diff",
|
|
62
|
+
label: "Apply Diff",
|
|
63
|
+
description: [
|
|
64
|
+
"Apply a complete unified diff directly to disk.",
|
|
65
|
+
"The diff MUST include a --- a/<path> / +++ b/<path> header and @@ hunk markers.",
|
|
66
|
+
].join(" "),
|
|
67
|
+
parameters: ApplyDiffParams,
|
|
68
|
+
|
|
69
|
+
async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
|
|
70
|
+
const validation = validateToolParams(ApplyDiffParams, rawParams, "apply_diff", errorDetails);
|
|
71
|
+
if (!validation.ok) return validation.error;
|
|
72
|
+
const { diff } = validation.value;
|
|
73
|
+
|
|
74
|
+
if (!diff.trim()) {
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: "text", text: "apply_diff requires a non-empty diff." }],
|
|
77
|
+
details: errorDetails(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// No `path` param → the model MUST include a complete header. Reject with a
|
|
82
|
+
// clear message instead of a silent fallback so a malformed diff is surfaced.
|
|
83
|
+
if (!hasValidHeader(diff)) {
|
|
84
|
+
return {
|
|
85
|
+
content: [{
|
|
86
|
+
type: "text",
|
|
87
|
+
text: "apply_diff requires a complete unified diff with --- a/<path> / +++ b/<path> header and @@ hunk markers.",
|
|
88
|
+
}],
|
|
89
|
+
details: errorDetails(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const path = firstDiffPath(diff);
|
|
94
|
+
const diffEdit: ProposedDiffEdit = { diff };
|
|
95
|
+
try {
|
|
96
|
+
await applyEdits([], [diffEdit], ctx);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return {
|
|
99
|
+
content: [{ type: "text", text: `apply_diff failed: ${errorMessage(e)}` }],
|
|
100
|
+
details: { status: "error", path },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text", text: `Applied diff to ${path}.` }],
|
|
106
|
+
details: { status: "done", path },
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
renderCall(args, theme) {
|
|
111
|
+
const path = firstDiffPath(args.diff);
|
|
112
|
+
return new Text(
|
|
113
|
+
[theme.fg("toolTitle", theme.bold("apply_diff ")), theme.fg("dim", `${path}: ${bodyPreview(args.diff)}`)].join(""),
|
|
114
|
+
0, 0,
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
renderResult(result, _options, theme) {
|
|
119
|
+
const details = result.details as ApplyDiffDetails | undefined;
|
|
120
|
+
const path = details?.path ? details.path : "(unknown path)";
|
|
121
|
+
const glyph = headlineStatusGlyph(details?.status === "error" ? "error" : "done", theme);
|
|
122
|
+
return new Text(`${glyph} ${theme.fg("toolTitle", theme.bold("apply_diff"))} · ${path}`, 0, 0);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Shared unsubscribe lifecycle for classes that attach to RlmEmitter events. */
|
|
2
|
+
|
|
3
|
+
import { errorMessage } from "../util/errors.ts";
|
|
4
|
+
|
|
5
|
+
export abstract class EmitterListener {
|
|
6
|
+
private readonly unsubs: (() => void)[] = [];
|
|
7
|
+
|
|
8
|
+
protected track(unsub: () => void): void {
|
|
9
|
+
this.unsubs.push(unsub);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
protected trackAll(unsubs: readonly (() => void)[]): void {
|
|
13
|
+
for (const unsub of unsubs) this.track(unsub);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Detach all registered listeners. Safe to call more than once. */
|
|
17
|
+
dispose(): void {
|
|
18
|
+
const unsubs = this.unsubs.splice(0);
|
|
19
|
+
for (const unsub of unsubs) {
|
|
20
|
+
try { unsub(); }
|
|
21
|
+
catch (error) { console.warn(`[rlm] listener cleanup failed: ${errorMessage(error)}`); }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReplDetails — structured payload for the repl() tool's AgentToolResult<T>.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors RlmDetails but scoped to a single code execution. Sub-calls (llm_query,
|
|
5
|
+
* rlm_query, todo, ask_user_question) triggered during sandbox execution are
|
|
6
|
+
* accumulated into the subcalls array for tree rendering.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { RlmSubcall } from "./rlm-details.ts";
|
|
10
|
+
|
|
11
|
+
export interface ReplDetails {
|
|
12
|
+
readonly status: "running" | "done" | "error";
|
|
13
|
+
/** stdout from the Python execution. */
|
|
14
|
+
readonly output: string;
|
|
15
|
+
/** stderr from the Python execution. */
|
|
16
|
+
readonly stderr: string;
|
|
17
|
+
/** Wall-clock execution time in milliseconds. */
|
|
18
|
+
readonly executionTimeMs: number;
|
|
19
|
+
/** Sub-calls triggered during this execution (llm_query, rlm_query, todo, etc.). */
|
|
20
|
+
readonly subcalls: readonly RlmSubcall[];
|
|
21
|
+
/** Running totals for this repl() call (cost + tokens from sub-LLM calls). */
|
|
22
|
+
readonly totals: { readonly costUsd: number; readonly tokens: number };
|
|
23
|
+
}
|