@cueloop/schema 0.1.0-alpha.0 → 0.1.0-alpha.100
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/package.json +16 -11
- package/src/agent-comment.test.ts +26 -0
- package/src/agent-comment.ts +17 -0
- package/src/agent-harness.ts +71 -0
- package/src/agent-input.test.ts +9 -0
- package/src/agent-input.ts +9 -0
- package/src/agent-submission.test.ts +15 -0
- package/src/agent-submission.ts +38 -0
- package/src/anchor.test.ts +340 -40
- package/src/anchor.ts +374 -43
- package/src/diff.test.ts +42 -25
- package/src/diff.ts +112 -79
- package/src/feedback.test.ts +318 -41
- package/src/feedback.ts +218 -55
- package/src/fuzzy.test.ts +110 -0
- package/src/fuzzy.ts +129 -0
- package/src/harness-output-routing.test.ts +18 -0
- package/src/harness-output-routing.ts +12 -0
- package/src/herdr.test.ts +50 -0
- package/src/herdr.ts +54 -0
- package/src/history.test.ts +401 -0
- package/src/history.ts +413 -0
- package/src/index.ts +19 -0
- package/src/inline.test.ts +195 -0
- package/src/inline.ts +234 -0
- package/src/markdown.test.ts +138 -22
- package/src/markdown.ts +332 -86
- package/src/participants.test.ts +105 -0
- package/src/participants.ts +51 -0
- package/src/path-view.test.ts +233 -0
- package/src/path-view.ts +105 -0
- package/src/share-access.test.ts +29 -0
- package/src/share-access.ts +14 -0
- package/src/thread-agent.ts +92 -0
- package/src/thread-surface.test.ts +6 -0
- package/src/thread-surface.ts +6 -0
- package/src/types.test.ts +79 -0
- package/src/types.ts +271 -32
- package/src/working-copy.test.ts +294 -0
- package/src/working-copy.ts +197 -0
package/src/feedback.ts
CHANGED
|
@@ -1,100 +1,263 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* feedback.md: the ONE structured markdown document the agent receives on
|
|
3
|
-
* resolve
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* resolve. Directive framing - soft phrasing gets ignored by models: the plan
|
|
4
|
+
* diff is applied verbatim first, then every annotation is addressed, located
|
|
5
|
+
* by quoted text.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
annotationTarget,
|
|
10
|
+
isAddressed,
|
|
11
|
+
isAgentNote,
|
|
12
|
+
type Annotation,
|
|
13
|
+
type ArtifactType,
|
|
14
|
+
type Thread,
|
|
15
|
+
type MessageOutcome,
|
|
16
|
+
} from "./types";
|
|
17
|
+
import { parseBlocks, sectionOf, type Block } from "./markdown";
|
|
10
18
|
import { resolveAnchor } from "./anchor";
|
|
11
19
|
import { unifiedDiffText } from "./diff";
|
|
12
20
|
|
|
13
21
|
export interface FeedbackInput {
|
|
14
|
-
|
|
22
|
+
outcome: MessageOutcome;
|
|
15
23
|
summary: string;
|
|
16
|
-
/** The submitted artifact content (latest revision). */
|
|
17
24
|
artifactContent: string;
|
|
18
|
-
/** The reviewer's working copy; undefined = no direct edits. */
|
|
19
25
|
workingCopy?: string;
|
|
26
|
+
artifactType?: ArtifactType;
|
|
20
27
|
annotations: Annotation[];
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
artifactPath?: string;
|
|
29
|
+
sessionId?: string;
|
|
30
|
+
actionBodies?: Record<string, string>;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
const quoteLines = (
|
|
33
|
+
const quoteLines = (text: string) => "> " + text.replace(/\n/g, "\n> ");
|
|
34
|
+
|
|
35
|
+
function expandActionRefs(body: string, actionBodies: Record<string, string> | undefined): string {
|
|
36
|
+
if (!actionBodies) return body;
|
|
37
|
+
|
|
38
|
+
return body.replace(/(^|\s)\/([A-Za-z0-9-]+)/g, (whole, lead: string, name: string) =>
|
|
39
|
+
actionBodies[name] !== undefined ? `${lead}${actionBodies[name]}` : whole,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
26
42
|
|
|
27
43
|
export function renderFeedback(input: FeedbackInput): string {
|
|
28
|
-
|
|
29
|
-
|
|
44
|
+
// The document the agent revises: a reply's feedback references reply.md so
|
|
45
|
+
// the resubmit instruction never points a reply review at plan.md.
|
|
46
|
+
const path = input.artifactPath ?? (input.artifactType === "reply" ? "reply.md" : "plan.md");
|
|
47
|
+
// agent notes are the submitter's own context - never echoed back as
|
|
48
|
+
// feedback - and annotations a previous revision already addressed stay out
|
|
49
|
+
// of the next document, so the agent only ever sees the open items
|
|
50
|
+
const open = input.annotations.filter(
|
|
51
|
+
(annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
|
|
52
|
+
);
|
|
53
|
+
// a discussion is one item: the root comment, then its replies in order. A
|
|
54
|
+
// reply whose root was addressed leaves with it; only a reply whose root is
|
|
55
|
+
// gone entirely stands on its own
|
|
56
|
+
const openIds = new Set(open.map((annotation) => annotation.id));
|
|
57
|
+
const knownIds = new Set(input.annotations.map((annotation) => annotation.id));
|
|
58
|
+
const annotations = open.filter(
|
|
59
|
+
(annotation) =>
|
|
60
|
+
annotation.replyTo === undefined ||
|
|
61
|
+
(!openIds.has(annotation.replyTo) && !knownIds.has(annotation.replyTo)),
|
|
62
|
+
);
|
|
63
|
+
const repliesTo = (root: Annotation): Annotation[] =>
|
|
64
|
+
open
|
|
65
|
+
.filter((annotation) => annotation.replyTo === root.id)
|
|
66
|
+
.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
67
|
+
const isDiff = input.artifactType === "diff";
|
|
68
|
+
const isPrototype = input.artifactType === "prototype";
|
|
69
|
+
// A diff's annotations anchor to the submitted patch rows; resolve against it,
|
|
70
|
+
// not the curated working copy (which may drop the rejected rows).
|
|
71
|
+
const blocks = parseBlocks(
|
|
72
|
+
isDiff ? input.artifactContent : (input.workingCopy ?? input.artifactContent),
|
|
73
|
+
);
|
|
30
74
|
const lines: string[] = [];
|
|
31
|
-
|
|
75
|
+
|
|
76
|
+
lines.push("# Review: " + input.outcome.replaceAll("_", " "));
|
|
32
77
|
lines.push("");
|
|
33
78
|
if (input.summary.trim()) {
|
|
34
79
|
lines.push(input.summary.trim());
|
|
35
80
|
lines.push("");
|
|
36
81
|
}
|
|
37
82
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
83
|
+
let hasEdits = false;
|
|
84
|
+
|
|
85
|
+
if (isDiff && input.workingCopy !== undefined) {
|
|
86
|
+
// The diff working copy is already the curated patch - the accepted subset
|
|
87
|
+
// of the submitted changes - so hand it back verbatim, not a diff of diffs.
|
|
88
|
+
hasEdits = true;
|
|
89
|
+
lines.push("## Curated changes");
|
|
42
90
|
lines.push("");
|
|
43
|
-
|
|
44
|
-
|
|
91
|
+
if (input.workingCopy.trim()) {
|
|
92
|
+
lines.push("The reviewer accepted a subset of your proposed changes. Apply this");
|
|
93
|
+
lines.push("exact unified diff; it replaces what you submitted.");
|
|
94
|
+
lines.push("");
|
|
95
|
+
lines.push("```diff");
|
|
96
|
+
lines.push(input.workingCopy);
|
|
97
|
+
lines.push("```");
|
|
98
|
+
} else {
|
|
99
|
+
lines.push("The reviewer rejected all of your proposed changes.");
|
|
100
|
+
}
|
|
45
101
|
lines.push("");
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
102
|
+
} else {
|
|
103
|
+
const diff =
|
|
104
|
+
input.workingCopy !== undefined
|
|
105
|
+
? unifiedDiffText(input.artifactContent, input.workingCopy, path)
|
|
106
|
+
: null;
|
|
107
|
+
|
|
108
|
+
if (diff) {
|
|
109
|
+
hasEdits = true;
|
|
110
|
+
// The edits section names the markdown artifact it edited (plan or reply)
|
|
111
|
+
// so the directive is not mislabelled when the artifact is not a plan.
|
|
112
|
+
lines.push(input.artifactType === "reply" ? "## Reply edits" : "## Plan edits");
|
|
113
|
+
lines.push("");
|
|
114
|
+
lines.push(`The reviewer edited ${path} directly. Apply this exact diff first;`);
|
|
115
|
+
lines.push("it is a unified diff against the version you submitted.");
|
|
116
|
+
lines.push("");
|
|
117
|
+
lines.push("```diff");
|
|
118
|
+
lines.push(diff);
|
|
119
|
+
lines.push("```");
|
|
120
|
+
lines.push("");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (annotations.length) {
|
|
125
|
+
lines.push(
|
|
126
|
+
...annotationSectionLines(
|
|
127
|
+
annotations,
|
|
128
|
+
blocks,
|
|
129
|
+
path,
|
|
130
|
+
isPrototype,
|
|
131
|
+
repliesTo,
|
|
132
|
+
input.actionBodies,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
if (input.sessionId) {
|
|
136
|
+
lines.push("## Reporting what you addressed");
|
|
137
|
+
lines.push("");
|
|
138
|
+
lines.push("When you resubmit the revised plan, list the annotation ids you acted on -");
|
|
139
|
+
lines.push("they are marked addressed for the reviewer and leave the open list:");
|
|
140
|
+
lines.push("");
|
|
141
|
+
lines.push("```sh");
|
|
142
|
+
lines.push(
|
|
143
|
+
`cueloop session submit-revision ${input.sessionId} --content-file ${path} --addressed <id,id,...>`,
|
|
144
|
+
);
|
|
145
|
+
lines.push("```");
|
|
146
|
+
lines.push("");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!hasEdits && !annotations.length) {
|
|
151
|
+
lines.push("_No edits or annotations._");
|
|
49
152
|
lines.push("");
|
|
50
153
|
}
|
|
51
154
|
|
|
52
|
-
|
|
53
|
-
|
|
155
|
+
return lines.join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The annotation body of the feedback, grouped by the surface each note was made on: the reviewed
|
|
160
|
+
* artifact first (resolved against its blocks for a section locator), then one section per file, so a
|
|
161
|
+
* note left on a file comes back beside that file rather than mixed into the plan's list.
|
|
162
|
+
*/
|
|
163
|
+
function annotationSectionLines(
|
|
164
|
+
annotations: Annotation[],
|
|
165
|
+
blocks: Block[],
|
|
166
|
+
path: string,
|
|
167
|
+
isPrototype: boolean,
|
|
168
|
+
repliesTo: (root: Annotation) => Annotation[],
|
|
169
|
+
actionBodies: Record<string, string> | undefined,
|
|
170
|
+
): string[] {
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
const artifactNotes = annotations.filter(
|
|
173
|
+
(annotation) => annotationTarget(annotation).kind === "artifact",
|
|
174
|
+
);
|
|
175
|
+
const fileGroups = new Map<string, Annotation[]>();
|
|
176
|
+
|
|
177
|
+
for (const annotation of annotations) {
|
|
178
|
+
const target = annotationTarget(annotation);
|
|
179
|
+
|
|
180
|
+
if (target.kind !== "file") continue;
|
|
181
|
+
const group = fileGroups.get(target.path) ?? [];
|
|
182
|
+
|
|
183
|
+
group.push(annotation);
|
|
184
|
+
fileGroups.set(target.path, group);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pushNote = (annotation: Annotation, index: number, location: string, orphan: string) => {
|
|
188
|
+
lines.push(`### ${index + 1}. ${capitalize(annotation.kind)}${location}${orphan}`);
|
|
189
|
+
lines.push("");
|
|
190
|
+
lines.push(quoteLines(annotation.anchor.quote));
|
|
54
191
|
lines.push("");
|
|
55
|
-
lines.push(
|
|
192
|
+
lines.push(expandActionRefs(annotation.body, actionBodies));
|
|
56
193
|
lines.push("");
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
lines.push(
|
|
64
|
-
lines.push("");
|
|
65
|
-
lines.push("Replace:");
|
|
66
|
-
lines.push(quoteLines(a.anchor.quote));
|
|
67
|
-
lines.push("With:");
|
|
68
|
-
lines.push(quoteLines(a.body));
|
|
69
|
-
} else {
|
|
70
|
-
lines.push(`### ${i + 1}. ${capitalize(a.kind)}${loc}${flag}`);
|
|
71
|
-
lines.push("");
|
|
72
|
-
lines.push(quoteLines(a.anchor.quote));
|
|
73
|
-
lines.push("");
|
|
74
|
-
lines.push(a.body);
|
|
75
|
-
}
|
|
194
|
+
const replies = repliesTo(annotation);
|
|
195
|
+
|
|
196
|
+
if (replies.length > 0) {
|
|
197
|
+
lines.push("Replies:");
|
|
198
|
+
lines.push("");
|
|
199
|
+
for (const reply of replies)
|
|
200
|
+
lines.push(`- ${expandActionRefs(reply.body, actionBodies).replace(/\n/g, "\n ")}`);
|
|
76
201
|
lines.push("");
|
|
202
|
+
}
|
|
203
|
+
lines.push(`annotation id: \`${annotation.id}\``);
|
|
204
|
+
lines.push("");
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (artifactNotes.length) {
|
|
208
|
+
lines.push(`## Annotations (${artifactNotes.length})`);
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push(
|
|
211
|
+
isPrototype
|
|
212
|
+
? `Address every item. Locate each one in ${path} by its CSS selector.`
|
|
213
|
+
: `Address every item. Locate each one in ${path} by its quoted text.`,
|
|
214
|
+
);
|
|
215
|
+
lines.push("");
|
|
216
|
+
artifactNotes.forEach((annotation, index) => {
|
|
217
|
+
// a prototype selector anchor never resolves or orphans against blocks; a quote anchor does
|
|
218
|
+
const selector = annotation.anchor.selector;
|
|
219
|
+
const resolved = selector ? null : resolveAnchor(annotation.anchor, blocks);
|
|
220
|
+
const sectionTitle = resolved ? sectionOf(blocks, resolved.blockIndex) : "";
|
|
221
|
+
const location = selector ? ` (${selector})` : sectionTitle ? ` (§ ${sectionTitle})` : "";
|
|
222
|
+
const orphan =
|
|
223
|
+
!selector && resolved === null
|
|
224
|
+
? " [orphaned anchor: the quoted text is no longer present]"
|
|
225
|
+
: "";
|
|
226
|
+
|
|
227
|
+
pushNote(annotation, index, location, orphan);
|
|
77
228
|
});
|
|
78
229
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
lines.push("
|
|
230
|
+
for (const [filePath, notes] of fileGroups) {
|
|
231
|
+
lines.push(`## ${filePath} (${notes.length})`);
|
|
232
|
+
lines.push("");
|
|
233
|
+
lines.push(`Address every item. Locate each one in ${filePath} by its quoted text.`);
|
|
82
234
|
lines.push("");
|
|
235
|
+
notes.forEach((annotation, index) => pushNote(annotation, index, "", ""));
|
|
83
236
|
}
|
|
84
|
-
|
|
237
|
+
|
|
238
|
+
return lines;
|
|
85
239
|
}
|
|
86
240
|
|
|
87
|
-
export function feedbackForSession(
|
|
241
|
+
export function feedbackForSession(
|
|
242
|
+
session: Thread,
|
|
243
|
+
outcome: MessageOutcome,
|
|
244
|
+
summary: string,
|
|
245
|
+
actionBodies?: Record<string, string>,
|
|
246
|
+
annotations: Annotation[] = session.annotations,
|
|
247
|
+
): string {
|
|
88
248
|
return renderFeedback({
|
|
89
|
-
|
|
249
|
+
outcome,
|
|
90
250
|
summary,
|
|
91
251
|
artifactContent: session.artifact.content,
|
|
92
252
|
workingCopy: session.workingCopy,
|
|
93
|
-
|
|
94
|
-
|
|
253
|
+
artifactType: session.artifact.type,
|
|
254
|
+
annotations,
|
|
255
|
+
artifactPath: session.artifact.meta.prototypePath ?? session.artifact.meta.planPath,
|
|
256
|
+
sessionId: session.id,
|
|
257
|
+
actionBodies,
|
|
95
258
|
});
|
|
96
259
|
}
|
|
97
260
|
|
|
98
|
-
function capitalize(
|
|
99
|
-
return
|
|
261
|
+
function capitalize(word: string): string {
|
|
262
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
100
263
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { fuzzyFindBestMatch, levenshteinDistance, similarityRatio } from "./fuzzy";
|
|
3
|
+
|
|
4
|
+
describe("levenshteinDistance", () => {
|
|
5
|
+
test("identical strings cost nothing", () => {
|
|
6
|
+
// Arrange / Act / Assert
|
|
7
|
+
expect(levenshteinDistance("anchor", "anchor")).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("counts a single substitution", () => {
|
|
11
|
+
// Arrange / Act / Assert
|
|
12
|
+
expect(levenshteinDistance("plan", "plane")).toBe(1);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("empty against non-empty costs the whole length", () => {
|
|
16
|
+
// Arrange / Act / Assert
|
|
17
|
+
expect(levenshteinDistance("", "quote")).toBe(5);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("is symmetric", () => {
|
|
21
|
+
// Arrange / Act / Assert
|
|
22
|
+
expect(levenshteinDistance("kitten", "sitting")).toBe(levenshteinDistance("sitting", "kitten"));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("similarityRatio", () => {
|
|
27
|
+
test("two empty strings are identical", () => {
|
|
28
|
+
// Arrange / Act / Assert
|
|
29
|
+
expect(similarityRatio("", "")).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("identical strings score one", () => {
|
|
33
|
+
// Arrange / Act / Assert
|
|
34
|
+
expect(similarityRatio("review", "review")).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a one-character typo in a long string stays close to one", () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
const original = "demonstration plan";
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
const ratio = similarityRatio(original, "demonstration plann");
|
|
43
|
+
|
|
44
|
+
// Assert
|
|
45
|
+
expect(ratio).toBeGreaterThan(0.9);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("unrelated strings score low", () => {
|
|
49
|
+
// Arrange / Act / Assert
|
|
50
|
+
expect(similarityRatio("abcdef", "zyxwvu")).toBeLessThan(0.2);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("fuzzyFindBestMatch", () => {
|
|
55
|
+
test("finds a lightly edited window and reports its offsets", () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
const haystack = "the quick brown fox jumps";
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
const match = fuzzyFindBestMatch("quikc brown", haystack, 0.75)!;
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(match).not.toBeNull();
|
|
64
|
+
expect(haystack.slice(match.start, match.end)).toBe("quick brown");
|
|
65
|
+
expect(match.similarity).toBeGreaterThan(0.75);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns null when nothing clears the similarity floor", () => {
|
|
69
|
+
// Arrange / Act
|
|
70
|
+
const match = fuzzyFindBestMatch("completely unrelated", "short text", 0.75);
|
|
71
|
+
|
|
72
|
+
// Assert
|
|
73
|
+
expect(match).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an empty needle or haystack never matches", () => {
|
|
77
|
+
// Arrange / Act / Assert
|
|
78
|
+
expect(fuzzyFindBestMatch("", "text", 0.75)).toBeNull();
|
|
79
|
+
expect(fuzzyFindBestMatch("text", "", 0.75)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("ties keep the earliest window", () => {
|
|
83
|
+
// Arrange
|
|
84
|
+
const haystack = "word word";
|
|
85
|
+
|
|
86
|
+
// Act
|
|
87
|
+
const match = fuzzyFindBestMatch("word", haystack, 0.9)!;
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
expect(match.start).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a long quote fuzzed over a long document bails within its work budget", () => {
|
|
94
|
+
// Arrange - a stale multi-line anchor whose text is gone, over a large document; the naive
|
|
95
|
+
// scan is O(haystack * needle^3) and would pin the CPU for a minute
|
|
96
|
+
const needle = Array.from({ length: 200 }, (_unused, index) => `token-${index}`).join(" ");
|
|
97
|
+
const haystack = Array.from(
|
|
98
|
+
{ length: 400 },
|
|
99
|
+
(_unused, index) => `line ${index} of the diff`,
|
|
100
|
+
).join("\n");
|
|
101
|
+
|
|
102
|
+
// Act
|
|
103
|
+
const started = Date.now();
|
|
104
|
+
const match = fuzzyFindBestMatch(needle, haystack, 0.75);
|
|
105
|
+
|
|
106
|
+
// Assert - it returns (here: no match clears the bar) fast, never spinning
|
|
107
|
+
expect(match).toBeNull();
|
|
108
|
+
expect(Date.now() - started).toBeLessThan(2000);
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/fuzzy.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approximate string matching for anchor re-binding. The anchor resolver falls
|
|
3
|
+
* back to these when exact and marker-normalized quote lookups both miss - the
|
|
4
|
+
* quoted text is still present but was lightly edited (a word changed, a typo
|
|
5
|
+
* fixed, punctuation adjusted). Everything here is pure and offset-exact so it
|
|
6
|
+
* can be tested in isolation from the resolver.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A window of the haystack that best matches the needle. */
|
|
10
|
+
export interface FuzzyMatch {
|
|
11
|
+
start: number;
|
|
12
|
+
end: number;
|
|
13
|
+
similarity: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Levenshtein edit distance: the fewest single-character insertions, deletions,
|
|
18
|
+
* or substitutions that turn `left` into `right`. Uses two rolling rows, so it
|
|
19
|
+
* costs O(left.length * right.length) time and O(right.length) space.
|
|
20
|
+
*/
|
|
21
|
+
export function levenshteinDistance(left: string, right: string): number {
|
|
22
|
+
if (left === right) return 0;
|
|
23
|
+
if (left.length === 0) return right.length;
|
|
24
|
+
if (right.length === 0) return left.length;
|
|
25
|
+
|
|
26
|
+
let previousRow: number[] = Array.from({ length: right.length + 1 }, (_unused, column) => column);
|
|
27
|
+
let currentRow: number[] = Array.from({ length: right.length + 1 }, () => 0);
|
|
28
|
+
|
|
29
|
+
for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) {
|
|
30
|
+
currentRow[0] = leftIndex;
|
|
31
|
+
for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
|
|
32
|
+
const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
|
|
33
|
+
|
|
34
|
+
currentRow[rightIndex] = Math.min(
|
|
35
|
+
currentRow[rightIndex - 1]! + 1, // insertion
|
|
36
|
+
previousRow[rightIndex]! + 1, // deletion
|
|
37
|
+
previousRow[rightIndex - 1]! + substitutionCost, // substitution
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const swap = previousRow;
|
|
41
|
+
|
|
42
|
+
previousRow = currentRow;
|
|
43
|
+
currentRow = swap;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return previousRow[right.length]!;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Similarity of two strings in [0, 1]: 1 is identical, 0 shares nothing. Derived
|
|
51
|
+
* from the edit distance normalized by the longer string. Two empty strings are
|
|
52
|
+
* defined as identical (1).
|
|
53
|
+
*/
|
|
54
|
+
export function similarityRatio(left: string, right: string): number {
|
|
55
|
+
const longestLength = Math.max(left.length, right.length);
|
|
56
|
+
|
|
57
|
+
if (longestLength === 0) return 1;
|
|
58
|
+
|
|
59
|
+
return 1 - levenshteinDistance(left, right) / longestLength;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Character comparisons a fuzzy search may spend before it gives up and returns
|
|
64
|
+
* the best window found so far (or null). The scan is O(haystack * needle^3) - a
|
|
65
|
+
* long quote fuzzed over a long document (a stale multi-block anchor whose text
|
|
66
|
+
* is gone) would otherwise pin the CPU for a minute; a passage that is truly gone
|
|
67
|
+
* is better left orphaned than fuzzed. Small everyday quotes never approach it.
|
|
68
|
+
*/
|
|
69
|
+
const FUZZY_WORK_BUDGET = 150_000_000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A mutable work allowance shared across a group of fuzzy searches. Resolving one
|
|
73
|
+
* anchor scans every block, so a per-call cap would still let a stale multi-block
|
|
74
|
+
* anchor spend budget x blocks; sharing one budget across those scans bounds the
|
|
75
|
+
* whole anchor to a single budget, not one per block.
|
|
76
|
+
*/
|
|
77
|
+
export interface FuzzyBudget {
|
|
78
|
+
remaining: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A fresh work allowance for one anchor's fuzzy search across its blocks. */
|
|
82
|
+
export function newFuzzyBudget(): FuzzyBudget {
|
|
83
|
+
return { remaining: FUZZY_WORK_BUDGET };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Find the haystack window most similar to the needle, or null when nothing
|
|
88
|
+
* clears `minimumSimilarity`. Windows range around the needle length so the
|
|
89
|
+
* match tolerates a few inserted or deleted characters, not only substitutions.
|
|
90
|
+
* Ties keep the earliest window. Offsets index the haystack directly. The scan
|
|
91
|
+
* spends from `budget` (its own single-budget allowance by default) and stops
|
|
92
|
+
* once the budget is exhausted, returning the best window so far - so a caller
|
|
93
|
+
* scanning many blocks can pass one budget to bound them together.
|
|
94
|
+
*/
|
|
95
|
+
export function fuzzyFindBestMatch(
|
|
96
|
+
needle: string,
|
|
97
|
+
haystack: string,
|
|
98
|
+
minimumSimilarity: number,
|
|
99
|
+
budget: FuzzyBudget = newFuzzyBudget(),
|
|
100
|
+
): FuzzyMatch | null {
|
|
101
|
+
if (needle === "" || haystack === "") return null;
|
|
102
|
+
|
|
103
|
+
const lengthTolerance = Math.max(2, Math.round(needle.length * 0.25));
|
|
104
|
+
const minimumWindowLength = Math.max(1, needle.length - lengthTolerance);
|
|
105
|
+
const maximumWindowLength = needle.length + lengthTolerance;
|
|
106
|
+
|
|
107
|
+
let bestMatch: FuzzyMatch | null = null;
|
|
108
|
+
|
|
109
|
+
for (let start = 0; start < haystack.length; start++) {
|
|
110
|
+
for (
|
|
111
|
+
let windowLength = minimumWindowLength;
|
|
112
|
+
windowLength <= maximumWindowLength && start + windowLength <= haystack.length;
|
|
113
|
+
windowLength++
|
|
114
|
+
) {
|
|
115
|
+
// each window costs a needle x window edit-distance pass; spend that from the shared budget
|
|
116
|
+
budget.remaining -= needle.length * windowLength;
|
|
117
|
+
if (budget.remaining <= 0) return bestMatch;
|
|
118
|
+
const window = haystack.slice(start, start + windowLength);
|
|
119
|
+
const similarity = similarityRatio(needle, window);
|
|
120
|
+
|
|
121
|
+
if (similarity < minimumSimilarity) continue;
|
|
122
|
+
if (bestMatch === null || similarity > bestMatch.similarity) {
|
|
123
|
+
bestMatch = { start, end: start + windowLength, similarity };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return bestMatch;
|
|
129
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { routeHarnessOutput } from "./harness-output-routing";
|
|
3
|
+
|
|
4
|
+
test("identical assistant and diagnostic wording takes different destinations", () => {
|
|
5
|
+
const text =
|
|
6
|
+
"[context] skill catalog shortened 112 descriptions: effective=20480 bytes source=compiled default\n";
|
|
7
|
+
|
|
8
|
+
expect(routeHarnessOutput({ kind: "message", text }).destination).toBe("thread");
|
|
9
|
+
expect(
|
|
10
|
+
routeHarnessOutput({
|
|
11
|
+
kind: "diagnostic",
|
|
12
|
+
text,
|
|
13
|
+
severity: "future-severity",
|
|
14
|
+
title: "Notice",
|
|
15
|
+
source: "protocol",
|
|
16
|
+
}).destination,
|
|
17
|
+
).toBe("diagnostics");
|
|
18
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AgentHarnessDiagnostic, AgentHarnessEvent } from "./agent-harness";
|
|
2
|
+
|
|
3
|
+
type RoutedHarnessOutput =
|
|
4
|
+
| { destination: "diagnostics"; event: AgentHarnessDiagnostic }
|
|
5
|
+
| { destination: "thread"; event: Exclude<AgentHarnessEvent, AgentHarnessDiagnostic> };
|
|
6
|
+
|
|
7
|
+
/** Route normalized harness output by provenance; assistant wording never changes its destination. */
|
|
8
|
+
export function routeHarnessOutput(event: AgentHarnessEvent): RoutedHarnessOutput {
|
|
9
|
+
if (event.kind === "diagnostic") return { destination: "diagnostics", event };
|
|
10
|
+
|
|
11
|
+
return { destination: "thread", event };
|
|
12
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { detectHerdr, insideHerdr, returnPaneFor } from "./herdr";
|
|
3
|
+
|
|
4
|
+
describe("detectHerdr", () => {
|
|
5
|
+
test("null outside herdr, context inside", () => {
|
|
6
|
+
expect(detectHerdr({})).toBeNull();
|
|
7
|
+
// no HERDR_ENV or no pane id means we are not in a usable herdr pane
|
|
8
|
+
expect(detectHerdr({ HERDR_PANE_ID: "p1" })).toBeNull();
|
|
9
|
+
expect(detectHerdr({ HERDR_ENV: "1" })).toBeNull();
|
|
10
|
+
// herdr 0.8.0 sets no HERDR_BIN_PATH: the binary defaults to `herdr` on PATH
|
|
11
|
+
expect(detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1" })).toEqual({
|
|
12
|
+
paneId: "p1",
|
|
13
|
+
binPath: "herdr",
|
|
14
|
+
});
|
|
15
|
+
// an explicit HERDR_BIN_PATH (tests, or a pinned install) still wins
|
|
16
|
+
expect(
|
|
17
|
+
detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1", HERDR_BIN_PATH: "/x/herdr" }),
|
|
18
|
+
).toEqual({
|
|
19
|
+
paneId: "p1",
|
|
20
|
+
binPath: "/x/herdr",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("insideHerdr", () => {
|
|
26
|
+
test("true only when HERDR_ENV is exactly 1", () => {
|
|
27
|
+
expect(insideHerdr({})).toBeFalse();
|
|
28
|
+
expect(insideHerdr({ HERDR_ENV: "0" })).toBeFalse();
|
|
29
|
+
expect(insideHerdr({ HERDR_ENV: "1" })).toBeTrue();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("returnPaneFor", () => {
|
|
34
|
+
test("outside herdr there is never a return target", () => {
|
|
35
|
+
expect(returnPaneFor("w1:p1", {})).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("env override wins, session meta is the fallback", () => {
|
|
39
|
+
// Arrange
|
|
40
|
+
const inside = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p9" };
|
|
41
|
+
|
|
42
|
+
// Assert
|
|
43
|
+
expect(returnPaneFor("w1:p1", inside)).toBe("w1:p1");
|
|
44
|
+
expect(returnPaneFor("w1:p1", { ...inside, CUELOOP_RETURN_PANE: "w1:p2" })).toBe("w1:p2");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returning to our own pane is a no-op", () => {
|
|
48
|
+
expect(returnPaneFor("w1:p1", { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1" })).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|