surgent 0.7.0-alpha.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 +407 -0
- package/bin/surgent.js +211 -0
- package/dist/optimizers/LICENSE +21 -0
- package/dist/optimizers/index.js +1984 -0
- package/dist/optimizers/index.js.map +7 -0
- package/dist/optimizers/package.json +31 -0
- package/package.json +45 -0
- package/src/agent/built-in/documenter.md +58 -0
- package/src/agent/built-in/general.md +107 -0
- package/src/agent/built-in/planner.md +73 -0
- package/src/agent/built-in/scout.md +97 -0
- package/src/agent/command.ts +140 -0
- package/src/agent/helpers.ts +95 -0
- package/src/agent/index.ts +9 -0
- package/src/agent/storage.ts +287 -0
- package/src/agent/types.ts +28 -0
- package/src/checkpoint/git.ts +173 -0
- package/src/checkpoint/index.ts +117 -0
- package/src/checkpoint/snapshot.ts +28 -0
- package/src/checkpoint/stage.ts +59 -0
- package/src/checkpoint/store.ts +108 -0
- package/src/cleanup/checkpoint.ts +31 -0
- package/src/cleanup/helpers.ts +24 -0
- package/src/cleanup/index.ts +21 -0
- package/src/cleanup/permission.ts +74 -0
- package/src/cleanup/subsession.ts +46 -0
- package/src/commands/helpers.ts +217 -0
- package/src/commands/index.ts +79 -0
- package/src/commands/render.ts +95 -0
- package/src/commands/types.ts +11 -0
- package/src/mcp-client/call-tool.ts +143 -0
- package/src/mcp-client/client.ts +90 -0
- package/src/mcp-client/command.ts +257 -0
- package/src/mcp-client/helpers.ts +153 -0
- package/src/mcp-client/index.ts +21 -0
- package/src/mcp-client/list-tools.ts +84 -0
- package/src/mcp-client/storage.ts +190 -0
- package/src/mcp-client/types.ts +34 -0
- package/src/mcp-client/validation.ts +115 -0
- package/src/optimizers/compactor/bash.ts +159 -0
- package/src/optimizers/compactor/grep.ts +141 -0
- package/src/optimizers/compactor/index.ts +132 -0
- package/src/optimizers/deduplicator/helpers.ts +75 -0
- package/src/optimizers/deduplicator/index.ts +23 -0
- package/src/optimizers/deduplicator/resources.ts +77 -0
- package/src/optimizers/deduplicator/state.ts +119 -0
- package/src/optimizers/deduplicator/types.ts +14 -0
- package/src/optimizers/entries.ts +104 -0
- package/src/optimizers/index.ts +17 -0
- package/src/optimizers/inspector/helpers.ts +60 -0
- package/src/optimizers/inspector/index.ts +89 -0
- package/src/optimizers/inspector/inspect.ts +88 -0
- package/src/optimizers/inspector/types.ts +7 -0
- package/src/optimizers/languages/go.ts +79 -0
- package/src/optimizers/languages/grammar.ts +200 -0
- package/src/optimizers/languages/index.ts +75 -0
- package/src/optimizers/languages/java.ts +64 -0
- package/src/optimizers/languages/python.ts +63 -0
- package/src/optimizers/languages/rust.ts +71 -0
- package/src/optimizers/languages/symbols.ts +95 -0
- package/src/optimizers/languages/tree-sitter-languages.d.ts +23 -0
- package/src/optimizers/languages/types.ts +134 -0
- package/src/optimizers/languages/typescript.ts +116 -0
- package/src/optimizers/mapper/files.ts +94 -0
- package/src/optimizers/mapper/index.ts +133 -0
- package/src/optimizers/mapper/types.ts +6 -0
- package/src/optimizers/pruner/cleanup.ts +121 -0
- package/src/optimizers/pruner/context.ts +46 -0
- package/src/optimizers/pruner/index.ts +45 -0
- package/src/optimizers/pruner/session.ts +34 -0
- package/src/optimizers/pruner/types.ts +18 -0
- package/src/permission/bash.ts +124 -0
- package/src/permission/command.ts +111 -0
- package/src/permission/components/prompt.ts +255 -0
- package/src/permission/components/rules-list.ts +342 -0
- package/src/permission/constants.ts +48 -0
- package/src/permission/helpers.ts +156 -0
- package/src/permission/index.ts +134 -0
- package/src/permission/pattern.ts +51 -0
- package/src/permission/piignore.ts +148 -0
- package/src/permission/precedence.ts +54 -0
- package/src/permission/resolution.ts +116 -0
- package/src/permission/storage.ts +142 -0
- package/src/permission/types.ts +57 -0
- package/src/questionnaire/component.ts +357 -0
- package/src/questionnaire/helpers.ts +220 -0
- package/src/questionnaire/index.ts +67 -0
- package/src/questionnaire/schemas.ts +50 -0
- package/src/questionnaire/types.ts +47 -0
- package/src/redactor/index.ts +34 -0
- package/src/redactor/patterns.ts +234 -0
- package/src/redactor/secrets.ts +113 -0
- package/src/subagent/helpers.ts +93 -0
- package/src/subagent/index.ts +81 -0
- package/src/subagent/storage.ts +100 -0
- package/src/subagent/subsession.ts +266 -0
- package/src/subagent/types.ts +83 -0
- package/src/subagent/validation.ts +100 -0
- package/src/ui/components/action-select-list.ts +165 -0
- package/src/ui/components/bash-mode.ts +281 -0
- package/src/ui/components/extended-select-list.ts +166 -0
- package/src/ui/components/form-field.ts +184 -0
- package/src/ui/components/form.ts +179 -0
- package/src/ui/components/frame.ts +60 -0
- package/src/ui/components/input-mode-indicator.ts +64 -0
- package/src/ui/components/keybound.ts +150 -0
- package/src/ui/components/lines.ts +27 -0
- package/src/ui/components/placeholder-input.ts +59 -0
- package/src/ui/components/scoped-input.ts +78 -0
- package/src/ui/components/scrollable-view.ts +155 -0
- package/src/ui/index.ts +40 -0
- package/src/utils.ts +206 -0
- package/src/web-tools/index.ts +15 -0
- package/src/web-tools/providers/brave.ts +55 -0
- package/src/web-tools/providers/firecrawl.ts +66 -0
- package/src/web-tools/providers/index.ts +50 -0
- package/src/web-tools/providers/jina.ts +48 -0
- package/src/web-tools/providers/native.ts +57 -0
- package/src/web-tools/providers/tavily.ts +56 -0
- package/src/web-tools/settings.ts +15 -0
- package/src/web-tools/web-fetch/helpers.ts +66 -0
- package/src/web-tools/web-fetch/index.ts +91 -0
- package/src/web-tools/web-fetch/parser.ts +51 -0
- package/src/web-tools/web-fetch/storage.ts +65 -0
- package/src/web-tools/web-fetch/types.ts +8 -0
- package/src/web-tools/web-login/helpers.ts +79 -0
- package/src/web-tools/web-login/index.ts +100 -0
- package/src/web-tools/web-login/types.ts +4 -0
- package/src/web-tools/web-search/helpers.ts +36 -0
- package/src/web-tools/web-search/index.ts +98 -0
- package/src/web-tools/web-search/types.ts +15 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type McpTransport = "stdio" | "http";
|
|
2
|
+
|
|
3
|
+
interface McpServerBase {
|
|
4
|
+
transport: McpTransport;
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
description?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StdioMcpServer extends McpServerBase {
|
|
10
|
+
transport: "stdio";
|
|
11
|
+
command: string;
|
|
12
|
+
args?: string[];
|
|
13
|
+
cwd?: string;
|
|
14
|
+
env?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface HttpMcpServer extends McpServerBase {
|
|
18
|
+
transport: "http";
|
|
19
|
+
url: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type McpServer = StdioMcpServer | HttpMcpServer;
|
|
24
|
+
|
|
25
|
+
export type ResolvedMcpServer = McpServer & {
|
|
26
|
+
name: string;
|
|
27
|
+
scope: "project" | "global";
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface McpToolCallDetails {
|
|
31
|
+
server: string;
|
|
32
|
+
transport: McpTransport;
|
|
33
|
+
remoteTool: string;
|
|
34
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import Value from "typebox/value";
|
|
3
|
+
import type { ResolvedMcpServer } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const editableResolvedMcpServerSchema = Type.Union([
|
|
6
|
+
Type.Object(
|
|
7
|
+
{
|
|
8
|
+
name: Type.String({ minLength: 1 }),
|
|
9
|
+
scope: Type.Union([Type.Literal("project"), Type.Literal("global")]),
|
|
10
|
+
transport: Type.Literal("stdio"),
|
|
11
|
+
enabled: Type.Boolean(),
|
|
12
|
+
description: Type.Optional(Type.String()),
|
|
13
|
+
command: Type.String({ minLength: 1 }),
|
|
14
|
+
args: Type.Optional(Type.Array(Type.String())),
|
|
15
|
+
cwd: Type.Optional(Type.String({ minLength: 1 })),
|
|
16
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
17
|
+
},
|
|
18
|
+
{ additionalProperties: false },
|
|
19
|
+
),
|
|
20
|
+
Type.Object(
|
|
21
|
+
{
|
|
22
|
+
name: Type.String({ minLength: 1 }),
|
|
23
|
+
scope: Type.Union([Type.Literal("project"), Type.Literal("global")]),
|
|
24
|
+
transport: Type.Literal("http"),
|
|
25
|
+
enabled: Type.Boolean(),
|
|
26
|
+
description: Type.Optional(Type.String()),
|
|
27
|
+
url: Type.String({ minLength: 1 }),
|
|
28
|
+
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
29
|
+
},
|
|
30
|
+
{ additionalProperties: false },
|
|
31
|
+
),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export function parseEditConfigValues(values: Record<string, string>): ResolvedMcpServer {
|
|
35
|
+
const enabledText = (values.enabled ?? "").trim().toLowerCase();
|
|
36
|
+
if (enabledText !== "true" && enabledText !== "false") {
|
|
37
|
+
throw new Error('enabled must be "true" or "false".');
|
|
38
|
+
}
|
|
39
|
+
const enabled = enabledText === "true";
|
|
40
|
+
|
|
41
|
+
const name = (values.name ?? "").trim();
|
|
42
|
+
const scope = (values.scope ?? "").trim();
|
|
43
|
+
const transport = (values.transport ?? "").trim();
|
|
44
|
+
const description = (values.description ?? "").trim();
|
|
45
|
+
|
|
46
|
+
if (transport === "stdio") {
|
|
47
|
+
const args = parseOptionalJsonValue(values.args, "args");
|
|
48
|
+
const env = parseOptionalJsonValue(values.env, "env");
|
|
49
|
+
const stdioServer = {
|
|
50
|
+
name,
|
|
51
|
+
scope,
|
|
52
|
+
transport: "stdio",
|
|
53
|
+
enabled,
|
|
54
|
+
command: (values.command ?? "").trim(),
|
|
55
|
+
...(description ? { description } : {}),
|
|
56
|
+
...((values.cwd ?? "").trim() ? { cwd: (values.cwd ?? "").trim() } : {}),
|
|
57
|
+
...(args !== undefined ? { args } : {}),
|
|
58
|
+
...(env !== undefined ? { env } : {}),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
validateWithTypebox(stdioServer);
|
|
62
|
+
return stdioServer as ResolvedMcpServer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (transport === "http") {
|
|
66
|
+
const headers = parseOptionalJsonValue(values.headers, "headers");
|
|
67
|
+
const httpServer = {
|
|
68
|
+
name,
|
|
69
|
+
scope,
|
|
70
|
+
transport: "http",
|
|
71
|
+
enabled,
|
|
72
|
+
url: (values.url ?? "").trim(),
|
|
73
|
+
...(description ? { description } : {}),
|
|
74
|
+
...(headers !== undefined ? { headers } : {}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
validateWithTypebox(httpServer);
|
|
78
|
+
return httpServer as ResolvedMcpServer;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw new Error('transport must be "stdio" or "http".');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseOptionalJsonValue(rawValue: string | undefined, fieldName: string): unknown {
|
|
85
|
+
const trimmedValue = (rawValue ?? "").trim();
|
|
86
|
+
if (!trimmedValue) return;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(trimmedValue);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Invalid JSON in ${fieldName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateWithTypebox(server: unknown) {
|
|
98
|
+
if (Value.Check(editableResolvedMcpServerSchema, server)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const validationErrors = Value.Errors(editableResolvedMcpServerSchema, server);
|
|
103
|
+
const firstError = validationErrors[0];
|
|
104
|
+
if (!firstError) {
|
|
105
|
+
throw new Error("Invalid MCP server config.");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const fieldPath = firstError.instancePath.replace(/^\//, "").replaceAll("/", ".").trim();
|
|
109
|
+
|
|
110
|
+
if (fieldPath.length > 0) {
|
|
111
|
+
throw new Error(`Invalid ${fieldPath}: ${firstError.message}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new Error(`Invalid MCP server config: ${firstError.message}`);
|
|
115
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { createLocalBashOperations, type BashOperations } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
const localBash = createLocalBashOperations();
|
|
5
|
+
const MIN_SIMILAR_LENGTH = 10;
|
|
6
|
+
const SIMILARITY_THRESHOLD = 0.8;
|
|
7
|
+
|
|
8
|
+
function isSimilarLine(previousLine: string, nextLine: string) {
|
|
9
|
+
if (Math.min(previousLine.length, nextLine.length) < MIN_SIMILAR_LENGTH) return false;
|
|
10
|
+
|
|
11
|
+
const longestLength = Math.max(previousLine.length, nextLine.length);
|
|
12
|
+
if (1 - Math.abs(previousLine.length - nextLine.length) / longestLength < SIMILARITY_THRESHOLD) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const distances = new Uint32Array(nextLine.length + 1);
|
|
17
|
+
for (let nextIndex = 0; nextIndex <= nextLine.length; nextIndex++) {
|
|
18
|
+
distances[nextIndex] = nextIndex;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (let previousIndex = 1; previousIndex <= previousLine.length; previousIndex++) {
|
|
22
|
+
let diagonalDistance = distances[0]!;
|
|
23
|
+
distances[0] = previousIndex;
|
|
24
|
+
|
|
25
|
+
for (let nextIndex = 1; nextIndex <= nextLine.length; nextIndex++) {
|
|
26
|
+
const upperDistance = distances[nextIndex]!;
|
|
27
|
+
const editCost = previousLine[previousIndex - 1] === nextLine[nextIndex - 1] ? 0 : 1;
|
|
28
|
+
distances[nextIndex] = Math.min(
|
|
29
|
+
upperDistance + 1,
|
|
30
|
+
distances[nextIndex - 1]! + 1,
|
|
31
|
+
diagonalDistance + editCost,
|
|
32
|
+
);
|
|
33
|
+
diagonalDistance = upperDistance;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return 1 - distances[nextLine.length]! / longestLength >= SIMILARITY_THRESHOLD;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class BashResultCompactor {
|
|
41
|
+
private readonly decoder = new TextDecoder();
|
|
42
|
+
private readonly onData: (data: Buffer) => void;
|
|
43
|
+
// Exact regex and carriage-return handling require holding one logical line.
|
|
44
|
+
private currentLine = "";
|
|
45
|
+
private carriageLine: string | undefined;
|
|
46
|
+
private hasPendingLine = false;
|
|
47
|
+
private pendingLine: string | undefined;
|
|
48
|
+
private pendingTerminated = false;
|
|
49
|
+
private omittedLines = 0;
|
|
50
|
+
private hasSimilarLines = false;
|
|
51
|
+
|
|
52
|
+
constructor(onData: (data: Buffer) => void) {
|
|
53
|
+
this.onData = onData;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
append(data: Buffer) {
|
|
57
|
+
this.process(this.decoder.decode(data, { stream: true }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
finish() {
|
|
61
|
+
this.process(this.decoder.decode());
|
|
62
|
+
if (this.hasPendingLine) {
|
|
63
|
+
this.emit(this.getNormalizedLine(), false);
|
|
64
|
+
this.resetLine();
|
|
65
|
+
}
|
|
66
|
+
this.flushPending();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private process(text: string) {
|
|
70
|
+
for (const character of text) {
|
|
71
|
+
if (character === "\r") {
|
|
72
|
+
if (
|
|
73
|
+
this.carriageLine === undefined ||
|
|
74
|
+
stripVTControlCharacters(this.currentLine).length > 0
|
|
75
|
+
) {
|
|
76
|
+
this.carriageLine = this.currentLine;
|
|
77
|
+
}
|
|
78
|
+
this.currentLine = "";
|
|
79
|
+
this.hasPendingLine = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (character === "\n") {
|
|
84
|
+
this.emit(this.getNormalizedLine(), true);
|
|
85
|
+
this.resetLine();
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.currentLine += character;
|
|
90
|
+
this.hasPendingLine = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private getNormalizedLine() {
|
|
95
|
+
const normalizedLine = stripVTControlCharacters(this.currentLine);
|
|
96
|
+
if (this.carriageLine !== undefined && normalizedLine.length === 0) {
|
|
97
|
+
return stripVTControlCharacters(this.carriageLine);
|
|
98
|
+
}
|
|
99
|
+
return normalizedLine;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private emit(normalizedLine: string, terminated: boolean) {
|
|
103
|
+
if (this.pendingLine === undefined) {
|
|
104
|
+
this.pendingLine = normalizedLine;
|
|
105
|
+
this.pendingTerminated = terminated;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (normalizedLine === this.pendingLine) {
|
|
110
|
+
this.omittedLines++;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (isSimilarLine(this.pendingLine, normalizedLine)) {
|
|
115
|
+
this.pendingLine = normalizedLine;
|
|
116
|
+
this.pendingTerminated = terminated;
|
|
117
|
+
this.omittedLines++;
|
|
118
|
+
this.hasSimilarLines = true;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.flushPending();
|
|
123
|
+
this.pendingLine = normalizedLine;
|
|
124
|
+
this.pendingTerminated = terminated;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private flushPending() {
|
|
128
|
+
if (this.pendingLine === undefined) return;
|
|
129
|
+
|
|
130
|
+
const suffix = this.hasSimilarLines ? ` (similar line x${this.omittedLines})` : "";
|
|
131
|
+
this.onData(Buffer.from(`${this.pendingLine}${suffix}${this.pendingTerminated ? "\n" : ""}`));
|
|
132
|
+
this.pendingLine = undefined;
|
|
133
|
+
this.pendingTerminated = false;
|
|
134
|
+
this.omittedLines = 0;
|
|
135
|
+
this.hasSimilarLines = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private resetLine() {
|
|
139
|
+
this.currentLine = "";
|
|
140
|
+
this.carriageLine = undefined;
|
|
141
|
+
this.hasPendingLine = false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createCompactingBashOperations(filter?: string): BashOperations {
|
|
146
|
+
return {
|
|
147
|
+
async exec(command, cwd, options) {
|
|
148
|
+
const compactor = new BashResultCompactor(options.onData);
|
|
149
|
+
try {
|
|
150
|
+
return await localBash.exec(command, cwd, {
|
|
151
|
+
...options,
|
|
152
|
+
onData: (data) => compactor.append(data),
|
|
153
|
+
});
|
|
154
|
+
} finally {
|
|
155
|
+
compactor.finish();
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
|
|
10
|
+
export function extractGrepSummary(contentText: string): string | null {
|
|
11
|
+
if (contentText === "No matches found") return null;
|
|
12
|
+
const notice = contentText.match(/\n\n(\[[^\n]+\])$/)?.[1];
|
|
13
|
+
|
|
14
|
+
const fileLines = new Map<string, Set<number>>();
|
|
15
|
+
const contentLines = contentText.split("\n");
|
|
16
|
+
let filePath: string | undefined;
|
|
17
|
+
let matched = false;
|
|
18
|
+
|
|
19
|
+
for (let lineIndex = 0; lineIndex < contentLines.length; lineIndex++) {
|
|
20
|
+
const contentLine = contentLines[lineIndex] ?? "";
|
|
21
|
+
const lineMatch = contentLine.match(/^(\d+): /);
|
|
22
|
+
if (!lineMatch) {
|
|
23
|
+
const nextLine = contentLines[lineIndex + 1] ?? "";
|
|
24
|
+
if (contentLine && !/^\d+- /.test(contentLine) && /^\d+[:-] /.test(nextLine)) {
|
|
25
|
+
filePath = contentLine;
|
|
26
|
+
}
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const lineNumStr = lineMatch[1];
|
|
31
|
+
if (!filePath || !lineNumStr) continue;
|
|
32
|
+
|
|
33
|
+
matched = true;
|
|
34
|
+
const lineNum = parseInt(lineNumStr, 10);
|
|
35
|
+
const existing = fileLines.get(filePath);
|
|
36
|
+
if (existing) {
|
|
37
|
+
existing.add(lineNum);
|
|
38
|
+
} else {
|
|
39
|
+
fileLines.set(filePath, new Set([lineNum]));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!matched) return null;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
Array.from(fileLines.entries())
|
|
47
|
+
.map(([filePath, lines]) => `${filePath}: lines_matched=[${Array.from(lines).join(", ")}]`)
|
|
48
|
+
.join("\n") + (notice ? `\n\n${notice}` : "")
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function rewriteTailWithSummaries(
|
|
53
|
+
sessionFile: string,
|
|
54
|
+
offset: number,
|
|
55
|
+
summaries: Map<string, string>,
|
|
56
|
+
) {
|
|
57
|
+
if (summaries.size === 0) return;
|
|
58
|
+
|
|
59
|
+
const sessionBuffer = readFileSync(sessionFile);
|
|
60
|
+
if (offset > sessionBuffer.length) return;
|
|
61
|
+
|
|
62
|
+
const prefixBuffer = sessionBuffer.subarray(0, offset);
|
|
63
|
+
const tailText = sessionBuffer.subarray(offset).toString("utf-8");
|
|
64
|
+
if (tailText.length === 0) return;
|
|
65
|
+
|
|
66
|
+
let changed = false;
|
|
67
|
+
const rewrittenTail = tailText
|
|
68
|
+
.split("\n")
|
|
69
|
+
.filter((line) => line.trim().length > 0)
|
|
70
|
+
.map((line) => {
|
|
71
|
+
const entry = JSON.parse(line) as Record<string, unknown>;
|
|
72
|
+
const message = entry.message as Record<string, unknown> | undefined;
|
|
73
|
+
if (
|
|
74
|
+
entry.type === "message" &&
|
|
75
|
+
message?.role === "toolResult" &&
|
|
76
|
+
typeof message.toolCallId === "string" &&
|
|
77
|
+
summaries.has(message.toolCallId)
|
|
78
|
+
) {
|
|
79
|
+
changed = true;
|
|
80
|
+
return JSON.stringify({
|
|
81
|
+
...entry,
|
|
82
|
+
message: {
|
|
83
|
+
...message,
|
|
84
|
+
content: [{ type: "text", text: summaries.get(message.toolCallId)! }],
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return line;
|
|
89
|
+
})
|
|
90
|
+
.join("\n");
|
|
91
|
+
|
|
92
|
+
if (!changed) return;
|
|
93
|
+
const tempFile = `${sessionFile}.${process.pid}.${Date.now()}.tmp`;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(tempFile, prefixBuffer);
|
|
97
|
+
appendFileSync(tempFile, `${rewrittenTail}\n`, "utf8");
|
|
98
|
+
renameSync(tempFile, sessionFile);
|
|
99
|
+
} finally {
|
|
100
|
+
if (existsSync(tempFile)) {
|
|
101
|
+
unlinkSync(tempFile);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatGrepResult(content: string): string {
|
|
107
|
+
const formattedLines: string[] = [];
|
|
108
|
+
const formattedIndexes = new Map<string, number>();
|
|
109
|
+
let currentFilePath: string | undefined;
|
|
110
|
+
let changed = false;
|
|
111
|
+
|
|
112
|
+
for (const line of content.split("\n")) {
|
|
113
|
+
const matchLine = line.match(/^(.+?):(\d+): (.*)$/);
|
|
114
|
+
const contextLine = matchLine ? null : line.match(/^(.+?)-(\d+)- (.*)$/);
|
|
115
|
+
const filePath = matchLine?.[1] ?? contextLine?.[1];
|
|
116
|
+
const lineNumber = matchLine?.[2] ?? contextLine?.[2];
|
|
117
|
+
const lineText = matchLine?.[3] ?? contextLine?.[3];
|
|
118
|
+
|
|
119
|
+
if (!filePath || !lineNumber || lineText === undefined) {
|
|
120
|
+
formattedLines.push(line);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const lineKey = `${filePath}\0${lineNumber}`;
|
|
125
|
+
const formattedIndex = formattedIndexes.get(lineKey);
|
|
126
|
+
if (formattedIndex !== undefined) {
|
|
127
|
+
if (matchLine) formattedLines[formattedIndex] = `${lineNumber}: ${lineText}`;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (filePath !== currentFilePath) {
|
|
132
|
+
formattedLines.push(filePath);
|
|
133
|
+
currentFilePath = filePath;
|
|
134
|
+
}
|
|
135
|
+
formattedIndexes.set(lineKey, formattedLines.length);
|
|
136
|
+
formattedLines.push(`${lineNumber}${matchLine ? ":" : "-"} ${lineText}`);
|
|
137
|
+
changed = true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return changed ? formattedLines.join("\n") : content;
|
|
141
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
createBashToolDefinition,
|
|
4
|
+
createGrepToolDefinition,
|
|
5
|
+
isGrepToolResult,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { createCompactingBashOperations } from "./bash.js";
|
|
9
|
+
import { rewriteTailWithSummaries, extractGrepSummary, formatGrepResult } from "./grep.js";
|
|
10
|
+
import Type from "typebox";
|
|
11
|
+
|
|
12
|
+
export default function (pi: ExtensionAPI) {
|
|
13
|
+
let writeStartOffset = 0;
|
|
14
|
+
const store = new Map<string, string>();
|
|
15
|
+
const grepTool = createGrepToolDefinition(process.cwd());
|
|
16
|
+
const bashTool = createBashToolDefinition(process.cwd(), {
|
|
17
|
+
operations: createCompactingBashOperations(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
pi.registerTool({
|
|
21
|
+
...grepTool,
|
|
22
|
+
parameters: Type.Object({
|
|
23
|
+
...grepTool.parameters.properties,
|
|
24
|
+
context: Type.Optional(
|
|
25
|
+
Type.Number({
|
|
26
|
+
description: "Number of lines to show before and after each match (default: 0)",
|
|
27
|
+
maximum: 3,
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
pi.registerTool({
|
|
34
|
+
...bashTool,
|
|
35
|
+
description:
|
|
36
|
+
"Execute a bash command in the current working directory. Strips ANSI escapes, collapses carriage-return updates, removes consecutive duplicate lines, then optionally filters lines with a JavaScript regex before truncating to the last 2000 lines or 50KB. Saved full output is compacted.",
|
|
37
|
+
parameters: Type.Object({
|
|
38
|
+
...bashTool.parameters.properties,
|
|
39
|
+
purpose: Type.String({
|
|
40
|
+
description: "Briefly explain what this command will do and why before running it",
|
|
41
|
+
minLength: 1,
|
|
42
|
+
maxLength: 256,
|
|
43
|
+
pattern: "\\S",
|
|
44
|
+
}),
|
|
45
|
+
}),
|
|
46
|
+
prepareArguments: undefined,
|
|
47
|
+
execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
48
|
+
return bashTool.execute(
|
|
49
|
+
toolCallId,
|
|
50
|
+
{ command: params.command, timeout: params.timeout },
|
|
51
|
+
signal,
|
|
52
|
+
onUpdate,
|
|
53
|
+
ctx,
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
59
|
+
if (store.size > 0) return;
|
|
60
|
+
|
|
61
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
62
|
+
if (!sessionFile) {
|
|
63
|
+
writeStartOffset = 0;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
writeStartOffset = statSync(sessionFile).size;
|
|
69
|
+
} catch {
|
|
70
|
+
writeStartOffset = 0;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
pi.on("agent_end", async (event) => {
|
|
75
|
+
for (const message of event.messages) {
|
|
76
|
+
if (message.role !== "toolResult" || message.toolName !== "grep") continue;
|
|
77
|
+
const text = message.content.find((content) => content.type === "text")?.text;
|
|
78
|
+
if (text === undefined) continue;
|
|
79
|
+
|
|
80
|
+
const summary = extractGrepSummary(text);
|
|
81
|
+
if (!summary) continue;
|
|
82
|
+
store.set(message.toolCallId, summary);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
pi.on("session_shutdown", (event, ctx) => {
|
|
87
|
+
if (store.size === 0) return;
|
|
88
|
+
|
|
89
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
90
|
+
if (sessionFile) {
|
|
91
|
+
rewriteTailWithSummaries(sessionFile, writeStartOffset, store);
|
|
92
|
+
}
|
|
93
|
+
if (event.targetSessionFile && event.targetSessionFile !== sessionFile) {
|
|
94
|
+
rewriteTailWithSummaries(event.targetSessionFile, writeStartOffset, store);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.on("context", async (event) => {
|
|
99
|
+
if (store.size === 0) return;
|
|
100
|
+
|
|
101
|
+
let changed = false;
|
|
102
|
+
for (const message of event.messages) {
|
|
103
|
+
if (message.role !== "toolResult" || message.toolName !== "grep") continue;
|
|
104
|
+
|
|
105
|
+
const summary = store.get(message.toolCallId);
|
|
106
|
+
if (summary === undefined) continue;
|
|
107
|
+
|
|
108
|
+
message.content = [{ type: "text", text: summary }];
|
|
109
|
+
changed = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!changed) return;
|
|
113
|
+
return { messages: event.messages };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
pi.on("tool_result", async (event) => {
|
|
117
|
+
if (!isGrepToolResult(event) || event.isError) return;
|
|
118
|
+
|
|
119
|
+
let changed = false;
|
|
120
|
+
const content = event.content.map((item) => {
|
|
121
|
+
if (item.type !== "text") return item;
|
|
122
|
+
|
|
123
|
+
const text = formatGrepResult(item.text);
|
|
124
|
+
if (text === item.text) return item;
|
|
125
|
+
|
|
126
|
+
changed = true;
|
|
127
|
+
return { ...item, text };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (changed) return { content };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Range } from "../inspector/types.js";
|
|
2
|
+
import type { ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { DeduplicatorState } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export function filterDeduplicatedMessages(
|
|
6
|
+
messages: ContextEvent["messages"],
|
|
7
|
+
state: DeduplicatorState,
|
|
8
|
+
): { changed: boolean; messages: ContextEvent["messages"] } {
|
|
9
|
+
const prunedIds = new Set<string>();
|
|
10
|
+
for (const [toolCallId, repeatIds] of state.replacements) {
|
|
11
|
+
if (repeatIds.every((repeatId) => state.resultEntryIds.has(repeatId))) {
|
|
12
|
+
prunedIds.add(toolCallId);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (prunedIds.size === 0) {
|
|
16
|
+
return { changed: false, messages };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const retained: ContextEvent["messages"] = [];
|
|
20
|
+
let changed = false;
|
|
21
|
+
for (const message of messages) {
|
|
22
|
+
if (message.role === "toolResult" && prunedIds.has(message.toolCallId)) {
|
|
23
|
+
changed = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (message.role !== "assistant") {
|
|
27
|
+
retained.push(message);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const retainedContent = message.content.filter(
|
|
32
|
+
(block) => block.type !== "toolCall" || !prunedIds.has(block.id),
|
|
33
|
+
);
|
|
34
|
+
if (retainedContent.length === message.content.length) {
|
|
35
|
+
retained.push(message);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
changed = true;
|
|
39
|
+
if (retainedContent.some((block) => block.type !== "thinking")) {
|
|
40
|
+
retained.push({ ...message, content: retainedContent });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { changed, messages: retained };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function mergeRanges(ranges: Range[]) {
|
|
47
|
+
const sorted = ranges.toSorted(([firstStart], [secondStart]) => firstStart - secondStart);
|
|
48
|
+
const merged: Range[] = [];
|
|
49
|
+
|
|
50
|
+
for (const range of sorted) {
|
|
51
|
+
const previousRange = merged.at(-1);
|
|
52
|
+
if (!previousRange || previousRange[1] < range[0] - 1) {
|
|
53
|
+
merged.push([range[0], range[1]]);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
previousRange[1] = Math.max(previousRange[1], range[1]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return merged;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function hasFullCoverage(range: Range, candidates: Range[]): boolean {
|
|
63
|
+
const intersections: Range[] = [];
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
const start = Math.max(range[0], candidate[0]);
|
|
66
|
+
const end = Math.min(range[1], candidate[1]);
|
|
67
|
+
if (start <= end) intersections.push([start, end]);
|
|
68
|
+
}
|
|
69
|
+
if (intersections.length === 0) return false;
|
|
70
|
+
|
|
71
|
+
const merged = mergeRanges(intersections);
|
|
72
|
+
return (
|
|
73
|
+
merged.reduce((lines, [start, end]) => lines + end - start + 1, 0) === range[1] - range[0] + 1
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readSessionEntries } from "../entries.js";
|
|
3
|
+
import { filterDeduplicatedMessages } from "./helpers.js";
|
|
4
|
+
import { buildDeduplicatorState } from "./state.js";
|
|
5
|
+
|
|
6
|
+
export default function (pi: ExtensionAPI) {
|
|
7
|
+
let state = buildDeduplicatorState([], null, "");
|
|
8
|
+
|
|
9
|
+
pi.on("session_start", (_event, ctx) => {
|
|
10
|
+
const entries = readSessionEntries(ctx.sessionManager.getSessionFile()) ?? [];
|
|
11
|
+
state = buildDeduplicatorState(entries, ctx.sessionManager.getLeafId(), ctx.cwd);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
pi.on("session_tree", (event, ctx) => {
|
|
15
|
+
const entries = readSessionEntries(ctx.sessionManager.getSessionFile()) ?? [];
|
|
16
|
+
state = buildDeduplicatorState(entries, event.newLeafId, ctx.cwd);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
pi.on("context", (event) => {
|
|
20
|
+
const deduplicated = filterDeduplicatedMessages(event.messages, state);
|
|
21
|
+
if (deduplicated.changed) return { messages: deduplicated.messages };
|
|
22
|
+
});
|
|
23
|
+
}
|