@cueloop/schema 0.1.0-alpha.59 → 0.1.0-alpha.60
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 +1 -1
- package/src/anchor.test.ts +1 -0
- package/src/anchor.ts +19 -0
- package/src/diff.test.ts +2 -0
- package/src/diff.ts +16 -0
- package/src/feedback.test.ts +19 -0
- package/src/feedback.ts +8 -1
- package/src/fuzzy.ts +7 -0
- package/src/herdr.ts +3 -0
- package/src/inline.test.ts +4 -0
- package/src/inline.ts +20 -0
- package/src/markdown.test.ts +4 -0
- package/src/markdown.ts +11 -0
- package/src/participants.ts +2 -0
- package/src/types.test.ts +10 -1
- package/src/types.ts +20 -3
- package/src/working-copy.test.ts +1 -0
- package/src/working-copy.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.60",
|
|
4
4
|
"description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
package/src/anchor.test.ts
CHANGED
|
@@ -63,6 +63,7 @@ describe("anchor cascade", () => {
|
|
|
63
63
|
|
|
64
64
|
// Assert
|
|
65
65
|
const editedBlockIndex = edited.findIndex((block) => block.text.includes("Review sessions"));
|
|
66
|
+
|
|
66
67
|
expect(resolved.blockIndex).toBe(editedBlockIndex);
|
|
67
68
|
expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
|
|
68
69
|
"Review sessions",
|
package/src/anchor.ts
CHANGED
|
@@ -42,6 +42,7 @@ export function makeAnchor(
|
|
|
42
42
|
end: number,
|
|
43
43
|
): Anchor {
|
|
44
44
|
const blockText = blocks[blockIndex]?.text ?? "";
|
|
45
|
+
|
|
45
46
|
return {
|
|
46
47
|
quote: blockText.slice(start, end),
|
|
47
48
|
prefix: blockText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
|
|
@@ -70,24 +71,29 @@ function contextScore(
|
|
|
70
71
|
const prefix = block.text.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
|
|
71
72
|
const suffix = block.text.slice(end, end + ANCHOR_CONTEXT_CHARS);
|
|
72
73
|
let score = 0;
|
|
74
|
+
|
|
73
75
|
if (prefix === anchor.prefix) score += 2;
|
|
74
76
|
if (suffix === anchor.suffix) score += 2;
|
|
75
77
|
if (anchor.blockIndex === blockIndex) score += 1;
|
|
76
78
|
if (anchor.blockIndex === blockIndex && anchor.start === start) score += 1;
|
|
79
|
+
|
|
77
80
|
return score;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/** Every verbatim occurrence of `quote` across the blocks, scored by context. */
|
|
81
84
|
function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
|
|
82
85
|
const candidates: Candidate[] = [];
|
|
86
|
+
|
|
83
87
|
blocks.forEach((block, blockIndex) => {
|
|
84
88
|
const text = block.text;
|
|
89
|
+
|
|
85
90
|
for (
|
|
86
91
|
let matchStart = text.indexOf(quote);
|
|
87
92
|
matchStart !== -1;
|
|
88
93
|
matchStart = text.indexOf(quote, matchStart + 1)
|
|
89
94
|
) {
|
|
90
95
|
const matchEnd = matchStart + quote.length;
|
|
96
|
+
|
|
91
97
|
candidates.push({
|
|
92
98
|
blockIndex,
|
|
93
99
|
start: matchStart,
|
|
@@ -96,6 +102,7 @@ function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Ca
|
|
|
96
102
|
});
|
|
97
103
|
}
|
|
98
104
|
});
|
|
105
|
+
|
|
99
106
|
return candidates;
|
|
100
107
|
}
|
|
101
108
|
|
|
@@ -115,14 +122,18 @@ function findFuzzyCandidates(
|
|
|
115
122
|
minimumSimilarity: number,
|
|
116
123
|
): Candidate[] {
|
|
117
124
|
const candidates: Candidate[] = [];
|
|
125
|
+
|
|
118
126
|
blocks.forEach((block, blockIndex) => {
|
|
119
127
|
const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity);
|
|
128
|
+
|
|
120
129
|
if (match === null) return;
|
|
121
130
|
const score =
|
|
122
131
|
match.similarity +
|
|
123
132
|
contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
|
|
133
|
+
|
|
124
134
|
candidates.push({ blockIndex, start: match.start, end: match.end, score });
|
|
125
135
|
});
|
|
136
|
+
|
|
126
137
|
return candidates;
|
|
127
138
|
}
|
|
128
139
|
|
|
@@ -130,6 +141,7 @@ function findFuzzyCandidates(
|
|
|
130
141
|
function pickBest(candidates: Candidate[]): Candidate | null {
|
|
131
142
|
if (candidates.length === 0) return null;
|
|
132
143
|
candidates.sort((left, right) => right.score - left.score);
|
|
144
|
+
|
|
133
145
|
return candidates[0]!;
|
|
134
146
|
}
|
|
135
147
|
|
|
@@ -148,25 +160,32 @@ export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor |
|
|
|
148
160
|
if (anchor.quote === "") return null;
|
|
149
161
|
|
|
150
162
|
const exact = pickBest(findExactCandidates(anchor, blocks, anchor.quote));
|
|
163
|
+
|
|
151
164
|
if (exact) return resolved(exact, "exact");
|
|
152
165
|
|
|
153
166
|
const trimmedQuote = anchor.quote.trim();
|
|
167
|
+
|
|
154
168
|
if (trimmedQuote !== "" && trimmedQuote !== anchor.quote) {
|
|
155
169
|
const trimmed = pickBest(findExactCandidates(anchor, blocks, trimmedQuote));
|
|
170
|
+
|
|
156
171
|
if (trimmed) return resolved(trimmed, "trimmed");
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
const normalizedQuote = stripLeadingBlockMarker(anchor.quote).trim();
|
|
175
|
+
|
|
160
176
|
if (normalizedQuote !== "" && normalizedQuote !== trimmedQuote) {
|
|
161
177
|
const normalized = pickBest(findExactCandidates(anchor, blocks, normalizedQuote));
|
|
178
|
+
|
|
162
179
|
if (normalized) return resolved(normalized, "normalized");
|
|
163
180
|
}
|
|
164
181
|
|
|
165
182
|
const fuzzyNeedle = normalizedQuote !== "" ? normalizedQuote : trimmedQuote;
|
|
183
|
+
|
|
166
184
|
if (fuzzyNeedle !== "") {
|
|
167
185
|
const fuzzy = pickBest(
|
|
168
186
|
findFuzzyCandidates(anchor, blocks, fuzzyNeedle, FUZZY_MINIMUM_SIMILARITY),
|
|
169
187
|
);
|
|
188
|
+
|
|
170
189
|
if (fuzzy) return resolved(fuzzy, "fuzzy");
|
|
171
190
|
}
|
|
172
191
|
|
package/src/diff.test.ts
CHANGED
|
@@ -30,6 +30,7 @@ describe("unifiedDiff", () => {
|
|
|
30
30
|
// Arrange
|
|
31
31
|
const oldLines = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`);
|
|
32
32
|
const newLines = [...oldLines];
|
|
33
|
+
|
|
33
34
|
newLines[1] = "first change";
|
|
34
35
|
newLines[27] = "second change";
|
|
35
36
|
|
|
@@ -50,6 +51,7 @@ describe("unifiedDiff", () => {
|
|
|
50
51
|
|
|
51
52
|
// Assert
|
|
52
53
|
const rebuilt: string[] = [];
|
|
54
|
+
|
|
53
55
|
for (const line of diffLines) {
|
|
54
56
|
if (line.kind === "hunk") continue;
|
|
55
57
|
if (line.kind === "ctx" || line.kind === "add") rebuilt.push(line.text.slice(1));
|
package/src/diff.ts
CHANGED
|
@@ -25,6 +25,7 @@ export function lcsDiff<T>(
|
|
|
25
25
|
const longestCommon: number[][] = Array.from({ length: oldCount + 1 }, () =>
|
|
26
26
|
Array.from({ length: newCount + 1 }, () => 0),
|
|
27
27
|
);
|
|
28
|
+
|
|
28
29
|
for (let oldIndex = oldCount - 1; oldIndex >= 0; oldIndex--) {
|
|
29
30
|
for (let newIndex = newCount - 1; newIndex >= 0; newIndex--) {
|
|
30
31
|
longestCommon[oldIndex]![newIndex] = equals(oldItems[oldIndex]!, newItems[newIndex]!)
|
|
@@ -38,6 +39,7 @@ export function lcsDiff<T>(
|
|
|
38
39
|
const ops: DiffOp<T>[] = [];
|
|
39
40
|
let oldIndex = 0;
|
|
40
41
|
let newIndex = 0;
|
|
42
|
+
|
|
41
43
|
while (oldIndex < oldCount && newIndex < newCount) {
|
|
42
44
|
if (equals(oldItems[oldIndex]!, newItems[newIndex]!)) {
|
|
43
45
|
ops.push({ kind: "ctx", oldValue: oldItems[oldIndex], newValue: newItems[newIndex] });
|
|
@@ -53,6 +55,7 @@ export function lcsDiff<T>(
|
|
|
53
55
|
}
|
|
54
56
|
while (oldIndex < oldCount) ops.push({ kind: "del", oldValue: oldItems[oldIndex++] });
|
|
55
57
|
while (newIndex < newCount) ops.push({ kind: "add", newValue: newItems[newIndex++] });
|
|
58
|
+
|
|
56
59
|
return ops;
|
|
57
60
|
}
|
|
58
61
|
|
|
@@ -67,6 +70,7 @@ export interface UnifiedLine {
|
|
|
67
70
|
*/
|
|
68
71
|
export function unifiedDiff(oldText: string, newText: string, context = 3): UnifiedLine[] | null {
|
|
69
72
|
const ops = lcsDiff(oldText.split("\n"), newText.split("\n"));
|
|
73
|
+
|
|
70
74
|
if (!ops.some((op) => op.kind !== "ctx")) return null;
|
|
71
75
|
let oldLineNumber = 1;
|
|
72
76
|
let newLineNumber = 1;
|
|
@@ -77,11 +81,14 @@ export function unifiedDiff(oldText: string, newText: string, context = 3): Unif
|
|
|
77
81
|
oldLine: op.kind !== "add" ? oldLineNumber : null,
|
|
78
82
|
newLine: op.kind !== "del" ? newLineNumber : null,
|
|
79
83
|
};
|
|
84
|
+
|
|
80
85
|
if (op.kind !== "add") oldLineNumber++;
|
|
81
86
|
if (op.kind !== "del") newLineNumber++;
|
|
87
|
+
|
|
82
88
|
return row;
|
|
83
89
|
});
|
|
84
90
|
const keep = Array.from({ length: rows.length }, () => false);
|
|
91
|
+
|
|
85
92
|
rows.forEach((row, rowIndex) => {
|
|
86
93
|
if (row.kind !== "ctx") {
|
|
87
94
|
for (
|
|
@@ -95,9 +102,11 @@ export function unifiedDiff(oldText: string, newText: string, context = 3): Unif
|
|
|
95
102
|
});
|
|
96
103
|
const hunks: { rows: (typeof rows)[number][] }[] = [];
|
|
97
104
|
let openHunk: { rows: (typeof rows)[number][] } | null = null;
|
|
105
|
+
|
|
98
106
|
rows.forEach((row, rowIndex) => {
|
|
99
107
|
if (!keep[rowIndex]) {
|
|
100
108
|
openHunk = null;
|
|
109
|
+
|
|
101
110
|
return;
|
|
102
111
|
}
|
|
103
112
|
if (!openHunk) {
|
|
@@ -107,11 +116,13 @@ export function unifiedDiff(oldText: string, newText: string, context = 3): Unif
|
|
|
107
116
|
openHunk.rows.push(row);
|
|
108
117
|
});
|
|
109
118
|
const lines: UnifiedLine[] = [];
|
|
119
|
+
|
|
110
120
|
for (const hunk of hunks) {
|
|
111
121
|
const oldStart = hunk.rows.find((row) => row.oldLine !== null)?.oldLine ?? 1;
|
|
112
122
|
const newStart = hunk.rows.find((row) => row.newLine !== null)?.newLine ?? 1;
|
|
113
123
|
const oldCount = hunk.rows.filter((row) => row.kind !== "add").length;
|
|
114
124
|
const newCount = hunk.rows.filter((row) => row.kind !== "del").length;
|
|
125
|
+
|
|
115
126
|
lines.push({ kind: "hunk", text: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` });
|
|
116
127
|
for (const row of hunk.rows) {
|
|
117
128
|
lines.push({
|
|
@@ -120,6 +131,7 @@ export function unifiedDiff(oldText: string, newText: string, context = 3): Unif
|
|
|
120
131
|
});
|
|
121
132
|
}
|
|
122
133
|
}
|
|
134
|
+
|
|
123
135
|
return lines;
|
|
124
136
|
}
|
|
125
137
|
|
|
@@ -131,7 +143,9 @@ export function unifiedDiffText(
|
|
|
131
143
|
context = 3,
|
|
132
144
|
): string | null {
|
|
133
145
|
const lines = unifiedDiff(oldText, newText, context);
|
|
146
|
+
|
|
134
147
|
if (!lines) return null;
|
|
148
|
+
|
|
135
149
|
return [`--- a/${path}`, `+++ b/${path}`, ...lines.map((line) => line.text)].join("\n");
|
|
136
150
|
}
|
|
137
151
|
|
|
@@ -142,7 +156,9 @@ export interface EditStats {
|
|
|
142
156
|
|
|
143
157
|
export function editStats(oldText: string, newText: string): EditStats {
|
|
144
158
|
const lines = unifiedDiff(oldText, newText, 0);
|
|
159
|
+
|
|
145
160
|
if (!lines) return { added: 0, removed: 0 };
|
|
161
|
+
|
|
146
162
|
return {
|
|
147
163
|
added: lines.filter((line) => line.kind === "add").length,
|
|
148
164
|
removed: lines.filter((line) => line.kind === "del").length,
|
package/src/feedback.test.ts
CHANGED
|
@@ -47,6 +47,24 @@ describe("renderFeedback", () => {
|
|
|
47
47
|
expect(feedback).toContain("+Sessions are written as one JSON record per session.");
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test("reply edits section is labelled for the reply artifact, not a plan", () => {
|
|
51
|
+
// Act
|
|
52
|
+
const feedback = renderFeedback({
|
|
53
|
+
verdictKind: "request_changes",
|
|
54
|
+
summary: "",
|
|
55
|
+
artifactContent: PLAN,
|
|
56
|
+
workingCopy: PLAN.replace("one JSON document", "one JSON record"),
|
|
57
|
+
artifactType: "reply",
|
|
58
|
+
annotations: [],
|
|
59
|
+
artifactPath: "reply.md",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(feedback).toContain("## Reply edits");
|
|
64
|
+
expect(feedback).not.toContain("## Plan edits");
|
|
65
|
+
expect(feedback).toContain("Apply this exact diff first");
|
|
66
|
+
});
|
|
67
|
+
|
|
50
68
|
test("a diff working copy is handed back as the curated patch, not a diff of diffs", () => {
|
|
51
69
|
// Arrange
|
|
52
70
|
const submittedPatch = `--- a/src/x.ts
|
|
@@ -175,6 +193,7 @@ describe("renderFeedback", () => {
|
|
|
175
193
|
}),
|
|
176
194
|
],
|
|
177
195
|
});
|
|
196
|
+
|
|
178
197
|
expect(mixed).toContain("## Annotations (1)");
|
|
179
198
|
expect(mixed).not.toContain("The agent's own explanation of the change.");
|
|
180
199
|
});
|
package/src/feedback.ts
CHANGED
|
@@ -52,6 +52,7 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
52
52
|
isDiff ? input.artifactContent : (input.workingCopy ?? input.artifactContent),
|
|
53
53
|
);
|
|
54
54
|
const lines: string[] = [];
|
|
55
|
+
|
|
55
56
|
lines.push("# Review: " + input.verdictKind.replace("_", " "));
|
|
56
57
|
lines.push("");
|
|
57
58
|
if (input.summary.trim()) {
|
|
@@ -60,6 +61,7 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
let hasEdits = false;
|
|
64
|
+
|
|
63
65
|
if (isDiff && input.workingCopy !== undefined) {
|
|
64
66
|
// The diff working copy is already the curated patch - the accepted subset
|
|
65
67
|
// of the submitted changes - so hand it back verbatim, not a diff of diffs.
|
|
@@ -82,9 +84,12 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
82
84
|
input.workingCopy !== undefined
|
|
83
85
|
? unifiedDiffText(input.artifactContent, input.workingCopy, path)
|
|
84
86
|
: null;
|
|
87
|
+
|
|
85
88
|
if (diff) {
|
|
86
89
|
hasEdits = true;
|
|
87
|
-
|
|
90
|
+
// The edits section names the markdown artifact it edited (plan or reply)
|
|
91
|
+
// so the directive is not mislabelled when the artifact is not a plan.
|
|
92
|
+
lines.push(input.artifactType === "reply" ? "## Reply edits" : "## Plan edits");
|
|
88
93
|
lines.push("");
|
|
89
94
|
lines.push(`The reviewer edited ${path} directly. Apply this exact diff first;`);
|
|
90
95
|
lines.push("it is a unified diff against the version you submitted.");
|
|
@@ -115,6 +120,7 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
115
120
|
!selector && resolved === null
|
|
116
121
|
? " [orphaned anchor: the quoted text is no longer present]"
|
|
117
122
|
: "";
|
|
123
|
+
|
|
118
124
|
lines.push(
|
|
119
125
|
`### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`,
|
|
120
126
|
);
|
|
@@ -145,6 +151,7 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
145
151
|
lines.push("_No edits or annotations._");
|
|
146
152
|
lines.push("");
|
|
147
153
|
}
|
|
154
|
+
|
|
148
155
|
return lines.join("\n");
|
|
149
156
|
}
|
|
150
157
|
|
package/src/fuzzy.ts
CHANGED
|
@@ -33,6 +33,7 @@ export function levenshteinDistance(left: string, right: string): number {
|
|
|
33
33
|
currentRow[0] = leftIndex;
|
|
34
34
|
for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
|
|
35
35
|
const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
|
|
36
|
+
|
|
36
37
|
currentRow[rightIndex] = Math.min(
|
|
37
38
|
currentRow[rightIndex - 1]! + 1, // insertion
|
|
38
39
|
previousRow[rightIndex]! + 1, // deletion
|
|
@@ -40,6 +41,7 @@ export function levenshteinDistance(left: string, right: string): number {
|
|
|
40
41
|
);
|
|
41
42
|
}
|
|
42
43
|
const swap = previousRow;
|
|
44
|
+
|
|
43
45
|
previousRow = currentRow;
|
|
44
46
|
currentRow = swap;
|
|
45
47
|
}
|
|
@@ -54,7 +56,9 @@ export function levenshteinDistance(left: string, right: string): number {
|
|
|
54
56
|
*/
|
|
55
57
|
export function similarityRatio(left: string, right: string): number {
|
|
56
58
|
const longestLength = Math.max(left.length, right.length);
|
|
59
|
+
|
|
57
60
|
if (longestLength === 0) return 1;
|
|
61
|
+
|
|
58
62
|
return 1 - levenshteinDistance(left, right) / longestLength;
|
|
59
63
|
}
|
|
60
64
|
|
|
@@ -76,6 +80,7 @@ export function fuzzyFindBestMatch(
|
|
|
76
80
|
const maximumWindowLength = needle.length + lengthTolerance;
|
|
77
81
|
|
|
78
82
|
let bestMatch: FuzzyMatch | null = null;
|
|
83
|
+
|
|
79
84
|
for (let start = 0; start < haystack.length; start++) {
|
|
80
85
|
for (
|
|
81
86
|
let windowLength = minimumWindowLength;
|
|
@@ -84,11 +89,13 @@ export function fuzzyFindBestMatch(
|
|
|
84
89
|
) {
|
|
85
90
|
const window = haystack.slice(start, start + windowLength);
|
|
86
91
|
const similarity = similarityRatio(needle, window);
|
|
92
|
+
|
|
87
93
|
if (similarity < minimumSimilarity) continue;
|
|
88
94
|
if (bestMatch === null || similarity > bestMatch.similarity) {
|
|
89
95
|
bestMatch = { start, end: start + windowLength, similarity };
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
}
|
|
99
|
+
|
|
93
100
|
return bestMatch;
|
|
94
101
|
}
|
package/src/herdr.ts
CHANGED
|
@@ -23,11 +23,13 @@ export type HerdrEnv = Record<string, string | undefined>;
|
|
|
23
23
|
export function detectHerdr(env: HerdrEnv = process.env): HerdrContext | null {
|
|
24
24
|
if (env.HERDR_ENV !== "1") return null;
|
|
25
25
|
const paneId = env.HERDR_PANE_ID;
|
|
26
|
+
|
|
26
27
|
if (!paneId) return null;
|
|
27
28
|
// herdr 0.8.0 exposes no HERDR_BIN_PATH; the CLI is `herdr` on PATH and finds
|
|
28
29
|
// the app over HERDR_SOCKET_PATH from the inherited env. Tests still override
|
|
29
30
|
// HERDR_BIN_PATH to point the spawn at a stub script.
|
|
30
31
|
const binPath = env.HERDR_BIN_PATH ?? "herdr";
|
|
32
|
+
|
|
31
33
|
return { paneId, binPath };
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -47,5 +49,6 @@ export function returnPaneFor(
|
|
|
47
49
|
): string | undefined {
|
|
48
50
|
if (!insideHerdr(env)) return undefined;
|
|
49
51
|
const pane = env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
|
|
52
|
+
|
|
50
53
|
return pane && pane !== env.HERDR_PANE_ID ? pane : undefined;
|
|
51
54
|
}
|
package/src/inline.test.ts
CHANGED
|
@@ -59,6 +59,7 @@ describe("inlineRuns", () => {
|
|
|
59
59
|
] as const) {
|
|
60
60
|
const runs = inlineRuns(source);
|
|
61
61
|
const content = runs.find((run) => run.role === role)!;
|
|
62
|
+
|
|
62
63
|
expect(content.text).toBe(inner);
|
|
63
64
|
expect(source.slice(content.start!, content.start! + inner.length)).toBe(inner);
|
|
64
65
|
expect(reconstruct(runs)).toBe(source);
|
|
@@ -74,6 +75,7 @@ describe("inlineRuns", () => {
|
|
|
74
75
|
|
|
75
76
|
// Assert - the backtick content is one literal code run, no strong inside
|
|
76
77
|
const code = runs.find((run) => run.role === "code")!;
|
|
78
|
+
|
|
77
79
|
expect(code.text).toBe("a**b**c");
|
|
78
80
|
expect(runs.some((run) => run.role === "strong")).toBe(false);
|
|
79
81
|
});
|
|
@@ -150,6 +152,7 @@ describe("inlineRuns", () => {
|
|
|
150
152
|
// Assert
|
|
151
153
|
for (const source of ["**not closed", "a * b", "`open code", "[label](noclose"]) {
|
|
152
154
|
const runs = inlineRuns(source);
|
|
155
|
+
|
|
153
156
|
expect(reconstruct(runs)).toBe(source);
|
|
154
157
|
expect(runs.every((run) => run.role === "text" || run.role === "marker")).toBe(true);
|
|
155
158
|
expect(offsetsAreExact(source, runs)).toBe(true);
|
|
@@ -174,6 +177,7 @@ describe("inlineRuns", () => {
|
|
|
174
177
|
expect(reconstruct(runs)).toBe("not \\*emph\\* here");
|
|
175
178
|
expect(offsetsAreExact("not \\*emph\\* here", runs)).toBe(true);
|
|
176
179
|
const star = runs.find((run) => run.text === "*" && run.start !== null)!;
|
|
180
|
+
|
|
177
181
|
expect("not \\*emph\\* here"[star.start!]).toBe("*");
|
|
178
182
|
});
|
|
179
183
|
|
package/src/inline.ts
CHANGED
|
@@ -44,7 +44,9 @@ const PUNCTUATION = new Set(["*", "_", "`", "~", "[", "]", "(", ")", "\\"]);
|
|
|
44
44
|
/** Tokenize one block's text into visible + concealed-marker runs, in order. */
|
|
45
45
|
export function inlineRuns(text: string): InlineRun[] {
|
|
46
46
|
const runs: InlineRun[] = [];
|
|
47
|
+
|
|
47
48
|
tokenize(text, 0, "text", runs);
|
|
49
|
+
|
|
48
50
|
return runs;
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -76,6 +78,7 @@ function tokenize(source: string, base: number, role: InlineRole, out: InlineRun
|
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
const link = matchLink(source, cursor);
|
|
81
|
+
|
|
79
82
|
if (link) {
|
|
80
83
|
flushPlain(cursor);
|
|
81
84
|
out.push({ text: source.slice(cursor, link.labelStart), role: "marker", start: null });
|
|
@@ -92,6 +95,7 @@ function tokenize(source: string, base: number, role: InlineRole, out: InlineRun
|
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
const code = matchCode(source, cursor);
|
|
98
|
+
|
|
95
99
|
if (code) {
|
|
96
100
|
flushPlain(cursor);
|
|
97
101
|
out.push({ text: source.slice(cursor, code.contentStart), role: "marker", start: null });
|
|
@@ -107,6 +111,7 @@ function tokenize(source: string, base: number, role: InlineRole, out: InlineRun
|
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
const span = matchEmphasis(source, cursor);
|
|
114
|
+
|
|
110
115
|
if (span) {
|
|
111
116
|
flushPlain(cursor);
|
|
112
117
|
out.push({
|
|
@@ -115,6 +120,7 @@ function tokenize(source: string, base: number, role: InlineRole, out: InlineRun
|
|
|
115
120
|
start: null,
|
|
116
121
|
});
|
|
117
122
|
const contentStart = cursor + span.openLength;
|
|
123
|
+
|
|
118
124
|
tokenize(source.slice(contentStart, span.closeIndex), base + contentStart, span.role, out);
|
|
119
125
|
out.push({
|
|
120
126
|
text: source.slice(span.closeIndex, span.closeIndex + span.closeLength),
|
|
@@ -145,14 +151,17 @@ function matchLink(
|
|
|
145
151
|
if (text[index] !== "[") return null;
|
|
146
152
|
const labelStart = index + 1;
|
|
147
153
|
const labelEnd = text.indexOf("]", labelStart);
|
|
154
|
+
|
|
148
155
|
if (labelEnd < 0 || labelEnd === labelStart) return null;
|
|
149
156
|
if (text[labelEnd + 1] !== "(") return null;
|
|
150
157
|
const hrefStart = labelEnd + 2;
|
|
151
158
|
// the destination may carry balanced parentheses (wiki URLs); the span ends
|
|
152
159
|
// at the first unmatched closer
|
|
153
160
|
let depth = 0;
|
|
161
|
+
|
|
154
162
|
for (let scan = hrefStart; scan < text.length; scan++) {
|
|
155
163
|
const character = text[scan];
|
|
164
|
+
|
|
156
165
|
if (character === "(") depth++;
|
|
157
166
|
else if (character === ")") {
|
|
158
167
|
if (depth > 0) {
|
|
@@ -160,9 +169,11 @@ function matchLink(
|
|
|
160
169
|
continue;
|
|
161
170
|
}
|
|
162
171
|
if (scan === hrefStart) return null;
|
|
172
|
+
|
|
163
173
|
return { labelStart, labelEnd, hrefStart, hrefEnd: scan, end: scan + 1 };
|
|
164
174
|
}
|
|
165
175
|
}
|
|
176
|
+
|
|
166
177
|
return null;
|
|
167
178
|
}
|
|
168
179
|
|
|
@@ -173,20 +184,25 @@ function matchCode(
|
|
|
173
184
|
): { contentStart: number; contentEnd: number; end: number } | null {
|
|
174
185
|
if (text[index] !== "`") return null;
|
|
175
186
|
let fence = 0;
|
|
187
|
+
|
|
176
188
|
while (text[index + fence] === "`") fence++;
|
|
177
189
|
const contentStart = index + fence;
|
|
178
190
|
const closer = "`".repeat(fence);
|
|
179
191
|
const contentEnd = text.indexOf(closer, contentStart);
|
|
192
|
+
|
|
180
193
|
if (contentEnd < 0 || contentEnd === contentStart) return null;
|
|
194
|
+
|
|
181
195
|
return { contentStart, contentEnd, end: contentEnd + fence };
|
|
182
196
|
}
|
|
183
197
|
|
|
184
198
|
/** A `**strong**`, `*em*`, or `~~strike~~` span at `index`, or null. */
|
|
185
199
|
function matchEmphasis(text: string, index: number): Span | null {
|
|
186
200
|
const two = text.slice(index, index + 2);
|
|
201
|
+
|
|
187
202
|
if (two === "**") return closingSpan(text, index, 2, "strong");
|
|
188
203
|
if (two === "~~") return closingSpan(text, index, 2, "strike");
|
|
189
204
|
if (text[index] === "*") return closingSpan(text, index, 1, "em");
|
|
205
|
+
|
|
190
206
|
return null;
|
|
191
207
|
}
|
|
192
208
|
|
|
@@ -201,13 +217,16 @@ function closingSpan(
|
|
|
201
217
|
const contentStart = index + length;
|
|
202
218
|
const closeIndex =
|
|
203
219
|
length === 1 ? findLoneStar(text, contentStart) : text.indexOf(marker, contentStart);
|
|
220
|
+
|
|
204
221
|
if (closeIndex < 0 || closeIndex === contentStart) return null; // unbalanced or empty
|
|
222
|
+
|
|
205
223
|
return { openLength: length, closeLength: length, closeIndex, role };
|
|
206
224
|
}
|
|
207
225
|
|
|
208
226
|
/** Index of the next standalone `*` (not part of a `**` run), or -1. */
|
|
209
227
|
function findLoneStar(text: string, from: number): number {
|
|
210
228
|
let index = from;
|
|
229
|
+
|
|
211
230
|
while (index < text.length) {
|
|
212
231
|
if (text[index] === "*") {
|
|
213
232
|
if (text[index + 1] !== "*") return index;
|
|
@@ -216,5 +235,6 @@ function findLoneStar(text: string, from: number): number {
|
|
|
216
235
|
}
|
|
217
236
|
index++;
|
|
218
237
|
}
|
|
238
|
+
|
|
219
239
|
return -1;
|
|
220
240
|
}
|
package/src/markdown.test.ts
CHANGED
|
@@ -47,6 +47,7 @@ describe("parseBlocks", () => {
|
|
|
47
47
|
"hr",
|
|
48
48
|
]);
|
|
49
49
|
const code = blocks.find((block) => block.kind === "code")!;
|
|
50
|
+
|
|
50
51
|
expect(code.lang).toBe("ts");
|
|
51
52
|
expect(code.text).toBe("const x = 1;\nconst y = 2;");
|
|
52
53
|
// fence lines included in the source range
|
|
@@ -84,6 +85,7 @@ describe("parseBlocks", () => {
|
|
|
84
85
|
const rebuilt = blocks
|
|
85
86
|
.map((block) => {
|
|
86
87
|
orderedItemCount = block.kind === "oli" ? orderedItemCount + 1 : 0;
|
|
88
|
+
|
|
87
89
|
return blockToMd(block, orderedItemCount || 1);
|
|
88
90
|
})
|
|
89
91
|
.join("\n\n");
|
|
@@ -117,8 +119,10 @@ describe("sectionOf", () => {
|
|
|
117
119
|
|
|
118
120
|
// Assert
|
|
119
121
|
const orderedItemIndex = blocks.findIndex((block) => block.kind === "oli");
|
|
122
|
+
|
|
120
123
|
expect(sectionOf(blocks, orderedItemIndex)).toBe("Steps");
|
|
121
124
|
const paragraphIndex = blocks.findIndex((block) => block.kind === "p");
|
|
125
|
+
|
|
122
126
|
expect(sectionOf(blocks, paragraphIndex)).toBe("Context");
|
|
123
127
|
});
|
|
124
128
|
});
|
package/src/markdown.ts
CHANGED
|
@@ -41,8 +41,10 @@ export function parseBlocks(markdown: string): Block[] {
|
|
|
41
41
|
const lines = markdown.split("\n");
|
|
42
42
|
const blocks: Block[] = [];
|
|
43
43
|
let lineIndex = 0;
|
|
44
|
+
|
|
44
45
|
while (lineIndex < lines.length) {
|
|
45
46
|
const line = lines[lineIndex]!;
|
|
47
|
+
|
|
46
48
|
if (line.trim() === "") {
|
|
47
49
|
lineIndex++;
|
|
48
50
|
continue;
|
|
@@ -50,13 +52,16 @@ export function parseBlocks(markdown: string): Block[] {
|
|
|
50
52
|
if (line.startsWith("```")) {
|
|
51
53
|
const start = lineIndex;
|
|
52
54
|
const lang = line.slice(3).trim() || undefined;
|
|
55
|
+
|
|
53
56
|
lineIndex++;
|
|
54
57
|
const body: string[] = [];
|
|
58
|
+
|
|
55
59
|
while (lineIndex < lines.length && !lines[lineIndex]!.startsWith("```")) {
|
|
56
60
|
body.push(lines[lineIndex]!);
|
|
57
61
|
lineIndex++;
|
|
58
62
|
}
|
|
59
63
|
const end = Math.min(lineIndex, lines.length - 1);
|
|
64
|
+
|
|
60
65
|
lineIndex++;
|
|
61
66
|
blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
|
|
62
67
|
} else if (line.startsWith("### ")) {
|
|
@@ -89,6 +94,7 @@ export function parseBlocks(markdown: string): Block[] {
|
|
|
89
94
|
} else if (line.startsWith("> ")) {
|
|
90
95
|
const start = lineIndex;
|
|
91
96
|
const body: string[] = [];
|
|
97
|
+
|
|
92
98
|
while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
|
|
93
99
|
body.push(stripLeadingBlockMarker(lines[lineIndex]!));
|
|
94
100
|
lineIndex++;
|
|
@@ -118,6 +124,7 @@ export function parseBlocks(markdown: string): Block[] {
|
|
|
118
124
|
} else {
|
|
119
125
|
const start = lineIndex;
|
|
120
126
|
const body: string[] = [];
|
|
127
|
+
|
|
121
128
|
while (
|
|
122
129
|
lineIndex < lines.length &&
|
|
123
130
|
lines[lineIndex]!.trim() !== "" &&
|
|
@@ -129,6 +136,7 @@ export function parseBlocks(markdown: string): Block[] {
|
|
|
129
136
|
blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
|
|
130
137
|
}
|
|
131
138
|
}
|
|
139
|
+
|
|
132
140
|
return blocks;
|
|
133
141
|
}
|
|
134
142
|
|
|
@@ -162,10 +170,13 @@ export function blockToMd(block: Block, ordinal = 1): string {
|
|
|
162
170
|
/** The section (nearest preceding heading) a block belongs to. */
|
|
163
171
|
export function sectionOf(blocks: Block[], index: number): string {
|
|
164
172
|
let sectionTitle = "";
|
|
173
|
+
|
|
165
174
|
for (let blockIndex = 0; blockIndex <= index && blockIndex < blocks.length; blockIndex++) {
|
|
166
175
|
const block = blocks[blockIndex]!;
|
|
176
|
+
|
|
167
177
|
if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3")
|
|
168
178
|
sectionTitle = block.text;
|
|
169
179
|
}
|
|
180
|
+
|
|
170
181
|
return sectionTitle;
|
|
171
182
|
}
|
package/src/participants.ts
CHANGED
|
@@ -21,12 +21,14 @@ export function registerParticipant(
|
|
|
21
21
|
const participants = session.participants ?? [];
|
|
22
22
|
const existing = participants.find((participant) => participant.id === author);
|
|
23
23
|
const trimmed = name?.trim();
|
|
24
|
+
|
|
24
25
|
if (existing && !trimmed) return session;
|
|
25
26
|
const next: Identity = {
|
|
26
27
|
id: author,
|
|
27
28
|
provider: "ssh",
|
|
28
29
|
...(trimmed ? { name: trimmed } : existing?.name ? { name: existing.name } : {}),
|
|
29
30
|
};
|
|
31
|
+
|
|
30
32
|
return {
|
|
31
33
|
...session,
|
|
32
34
|
participants: existing
|
package/src/types.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { newAnnotationId, verdictAllows } from "./types";
|
|
2
|
+
import { isMarkdownArtifact, newAnnotationId, verdictAllows } from "./types";
|
|
3
3
|
|
|
4
4
|
describe("verdictAllows", () => {
|
|
5
5
|
test("only approve maps to allow", () => {
|
|
@@ -9,6 +9,15 @@ describe("verdictAllows", () => {
|
|
|
9
9
|
});
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
+
describe("isMarkdownArtifact", () => {
|
|
13
|
+
test("plan and reply are markdown; diff and prototype are not", () => {
|
|
14
|
+
expect(isMarkdownArtifact("plan")).toBe(true);
|
|
15
|
+
expect(isMarkdownArtifact("reply")).toBe(true);
|
|
16
|
+
expect(isMarkdownArtifact("diff")).toBe(false);
|
|
17
|
+
expect(isMarkdownArtifact("prototype")).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
12
21
|
describe("newAnnotationId", () => {
|
|
13
22
|
test("unique by construction within a process, even on one millisecond", () => {
|
|
14
23
|
// Act
|
package/src/types.ts
CHANGED
|
@@ -12,14 +12,30 @@ export interface WorkspaceKey {
|
|
|
12
12
|
branch: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* What kind of artifact a review session holds. `plan` and `reply` are both
|
|
17
|
+
* markdown documents (see isMarkdownArtifact) - a plan is a proposal written
|
|
18
|
+
* forward, a reply is the agent's previous message pulled back for review.
|
|
19
|
+
* `diff` is a unified-diff patch; `prototype` is a rendered HTML page.
|
|
20
|
+
*/
|
|
21
|
+
export type ArtifactType = "plan" | "diff" | "prototype" | "reply";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Markdown artifacts (plan, reply) are block-parsed and quote-anchored, so they
|
|
25
|
+
* share the plan render path, first-heading title derivation, and revision
|
|
26
|
+
* drift-assist. A diff (a patch) and a prototype (HTML/DOM) do not - keep this
|
|
27
|
+
* the one place that names the set, so a new markdown primitive joins here once.
|
|
28
|
+
*/
|
|
29
|
+
export function isMarkdownArtifact(type: ArtifactType): boolean {
|
|
30
|
+
return type === "plan" || type === "reply";
|
|
31
|
+
}
|
|
16
32
|
|
|
17
33
|
export interface ArtifactMeta {
|
|
18
34
|
cwd?: string;
|
|
19
35
|
agent?: string;
|
|
20
36
|
/** Agent-native session id, for resume/fork context. */
|
|
21
37
|
agentSessionId?: string;
|
|
22
|
-
/** Path to the plan file on disk, so feedback can reference it. */
|
|
38
|
+
/** Path to the plan or reply markdown file on disk, so feedback can reference it. */
|
|
23
39
|
planPath?: string;
|
|
24
40
|
/** Path to the prototype's entry HTML file on disk. */
|
|
25
41
|
prototypePath?: string;
|
|
@@ -45,7 +61,7 @@ export interface DiffFileContents {
|
|
|
45
61
|
|
|
46
62
|
export interface Artifact {
|
|
47
63
|
type: ArtifactType;
|
|
48
|
-
/** Markdown source for plans; unified-diff text for diffs. */
|
|
64
|
+
/** Markdown source for plans and replies; unified-diff text for diffs. */
|
|
49
65
|
content: string;
|
|
50
66
|
meta: ArtifactMeta;
|
|
51
67
|
/**
|
|
@@ -201,6 +217,7 @@ export function verdictAllows(kind: VerdictKind): boolean {
|
|
|
201
217
|
* annotators in different processes on the same millisecond.
|
|
202
218
|
*/
|
|
203
219
|
let annotationSeq = 0;
|
|
220
|
+
|
|
204
221
|
export function newAnnotationId(): string {
|
|
205
222
|
return `a_${Date.now().toString(36)}${(annotationSeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
206
223
|
}
|
package/src/working-copy.test.ts
CHANGED
|
@@ -139,6 +139,7 @@ describe("restoreBlock", () => {
|
|
|
139
139
|
).toBeUndefined();
|
|
140
140
|
// a real text change keeps the working copy alive
|
|
141
141
|
const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
|
|
142
|
+
|
|
142
143
|
expect(changed).toContain("1st item");
|
|
143
144
|
});
|
|
144
145
|
});
|
package/src/working-copy.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { parseBlocks, type Block } from "./markdown";
|
|
|
10
10
|
/** Chunk of the base source a block occupies (for restore and display). */
|
|
11
11
|
export function sourceChunk(base: string, block: Block): string {
|
|
12
12
|
const lines = base.split("\n");
|
|
13
|
+
|
|
13
14
|
return lines.slice(block.lineStart, block.lineEnd + 1).join("\n");
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -18,7 +19,9 @@ export function cutBlock(working: string, block: Block): string {
|
|
|
18
19
|
const lines = working.split("\n");
|
|
19
20
|
const before = lines.slice(0, block.lineStart);
|
|
20
21
|
const after = lines.slice(block.lineEnd + 1);
|
|
22
|
+
|
|
21
23
|
while (before.length && before[before.length - 1]!.trim() === "") before.pop();
|
|
24
|
+
|
|
22
25
|
return [...before, ...after].join("\n");
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -54,8 +57,10 @@ export function restoreBlock(
|
|
|
54
57
|
const before = lines.slice(0, beforeLine);
|
|
55
58
|
const after = lines.slice(beforeLine);
|
|
56
59
|
const insertedLines = sourceChunk(base, block).split("\n");
|
|
60
|
+
|
|
57
61
|
if (before.length && before[before.length - 1]!.trim() !== "") insertedLines.unshift("");
|
|
58
62
|
if (after.length && after[0]!.trim() !== "") insertedLines.push("");
|
|
59
63
|
const restored = [...before, ...insertedLines, ...after].join("\n");
|
|
64
|
+
|
|
60
65
|
return signature(restored) === signature(base) ? undefined : restored;
|
|
61
66
|
}
|