@cueloop/schema 0.1.0-alpha.5 → 0.1.0-alpha.50

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/src/feedback.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * feedback.md: the ONE structured markdown document the agent receives on
3
- * resolve (map #2, verified in the edit-mode deep-dive). Directive framing -
4
- * soft phrasing gets ignored by models: the plan diff is applied verbatim
5
- * first, then every annotation is addressed, located by quoted text.
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 type { Annotation, ReviewSession, VerdictKind } from "./types";
8
+ import {
9
+ isAddressed,
10
+ isAgentNote,
11
+ type Annotation,
12
+ type ArtifactType,
13
+ type ReviewSession,
14
+ type VerdictKind,
15
+ } from "./types";
9
16
  import { parseBlocks, sectionOf } from "./markdown";
10
17
  import { resolveAnchor } from "./anchor";
11
18
  import { unifiedDiffText } from "./diff";
@@ -15,18 +22,35 @@ export interface FeedbackInput {
15
22
  summary: string;
16
23
  /** The submitted artifact content (latest revision). */
17
24
  artifactContent: string;
18
- /** The reviewer's working copy; undefined = no direct edits. */
25
+ /** The reviewer's working copy; undefined = no direct edits. For a diff it is
26
+ * the curated patch (accepted hunks); for a plan it is the edited source. */
19
27
  workingCopy?: string;
28
+ /** Artifact kind; defaults to "plan". A diff working copy is already a patch. */
29
+ artifactType?: ArtifactType;
20
30
  annotations: Annotation[];
21
- /** Path the agent knows the plan by, for direct reference. */
22
- planPath?: string;
31
+ /** Path the agent knows the artifact by (plan or prototype), for direct reference. */
32
+ artifactPath?: string;
33
+ /** Session id, so the document can teach the addressed-ids resubmit call. */
34
+ sessionId?: string;
23
35
  }
24
36
 
25
- const quoteLines = (s: string) => "> " + s.replace(/\n/g, "\n> ");
37
+ const quoteLines = (text: string) => "> " + text.replace(/\n/g, "\n> ");
26
38
 
27
39
  export function renderFeedback(input: FeedbackInput): string {
28
- const path = input.planPath ?? "plan.md";
29
- const blocks = parseBlocks(input.workingCopy ?? input.artifactContent);
40
+ const path = input.artifactPath ?? "plan.md";
41
+ // agent notes are the submitter's own context - never echoed back as
42
+ // feedback - and annotations a previous revision already addressed stay out
43
+ // of the next document, so the agent only ever sees the open items
44
+ const annotations = input.annotations.filter(
45
+ (annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
46
+ );
47
+ const isDiff = input.artifactType === "diff";
48
+ const isPrototype = input.artifactType === "prototype";
49
+ // A diff's annotations anchor to the submitted patch rows; resolve against it,
50
+ // not the curated working copy (which may drop the rejected rows).
51
+ const blocks = parseBlocks(
52
+ isDiff ? input.artifactContent : (input.workingCopy ?? input.artifactContent),
53
+ );
30
54
  const lines: string[] = [];
31
55
  lines.push("# Review: " + input.verdictKind.replace("_", " "));
32
56
  lines.push("");
@@ -35,66 +59,112 @@ export function renderFeedback(input: FeedbackInput): string {
35
59
  lines.push("");
36
60
  }
37
61
 
38
- const diff =
39
- input.workingCopy !== undefined ? unifiedDiffText(input.artifactContent, input.workingCopy, path) : null;
40
- if (diff) {
41
- lines.push("## Plan edits");
62
+ let hasEdits = false;
63
+ if (isDiff && input.workingCopy !== undefined) {
64
+ // The diff working copy is already the curated patch - the accepted subset
65
+ // of the submitted changes - so hand it back verbatim, not a diff of diffs.
66
+ hasEdits = true;
67
+ lines.push("## Curated changes");
42
68
  lines.push("");
43
- lines.push(`The reviewer edited ${path} directly. Apply this exact diff first;`);
44
- lines.push("it is a unified diff against the version you submitted.");
45
- lines.push("");
46
- lines.push("```diff");
47
- lines.push(diff);
48
- lines.push("```");
69
+ if (input.workingCopy.trim()) {
70
+ lines.push("The reviewer accepted a subset of your proposed changes. Apply this");
71
+ lines.push("exact unified diff; it replaces what you submitted.");
72
+ lines.push("");
73
+ lines.push("```diff");
74
+ lines.push(input.workingCopy);
75
+ lines.push("```");
76
+ } else {
77
+ lines.push("The reviewer rejected all of your proposed changes.");
78
+ }
49
79
  lines.push("");
80
+ } else {
81
+ const diff =
82
+ input.workingCopy !== undefined
83
+ ? unifiedDiffText(input.artifactContent, input.workingCopy, path)
84
+ : null;
85
+ if (diff) {
86
+ hasEdits = true;
87
+ lines.push("## Plan edits");
88
+ lines.push("");
89
+ lines.push(`The reviewer edited ${path} directly. Apply this exact diff first;`);
90
+ lines.push("it is a unified diff against the version you submitted.");
91
+ lines.push("");
92
+ lines.push("```diff");
93
+ lines.push(diff);
94
+ lines.push("```");
95
+ lines.push("");
96
+ }
50
97
  }
51
98
 
52
- if (input.annotations.length) {
53
- lines.push(`## Annotations (${input.annotations.length})`);
99
+ if (annotations.length) {
100
+ lines.push(`## Annotations (${annotations.length})`);
54
101
  lines.push("");
55
- lines.push(`Address every item. Locate each one in ${path} by its quoted text.`);
102
+ lines.push(
103
+ isPrototype
104
+ ? `Address every item. Locate each one in ${path} by its CSS selector.`
105
+ : `Address every item. Locate each one in ${path} by its quoted text.`,
106
+ );
56
107
  lines.push("");
57
- input.annotations.forEach((a, i) => {
58
- const res = resolveAnchor(a.anchor, blocks);
59
- const sec = res ? sectionOf(blocks, res.blockIndex) : "";
60
- const loc = sec ? ` (§ ${sec})` : "";
61
- const flag = res === null ? " [orphaned anchor: the quoted text is no longer present]" : "";
62
- if (a.kind === "suggestion") {
63
- lines.push(`### ${i + 1}. Suggested change${loc}${flag}`);
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
- }
108
+ annotations.forEach((annotation, annotationIndex) => {
109
+ // a prototype selector anchor is never resolved or orphaned against blocks
110
+ const selector = annotation.anchor.selector;
111
+ const resolved = selector ? null : resolveAnchor(annotation.anchor, blocks);
112
+ const sectionTitle = resolved ? sectionOf(blocks, resolved.blockIndex) : "";
113
+ const location = selector ? ` (${selector})` : sectionTitle ? ` (§ ${sectionTitle})` : "";
114
+ const orphanFlag =
115
+ !selector && resolved === null
116
+ ? " [orphaned anchor: the quoted text is no longer present]"
117
+ : "";
118
+ lines.push(
119
+ `### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`,
120
+ );
121
+ lines.push("");
122
+ lines.push(quoteLines(annotation.anchor.quote));
123
+ lines.push("");
124
+ lines.push(annotation.body);
125
+ lines.push("");
126
+ lines.push(`annotation id: \`${annotation.id}\``);
76
127
  lines.push("");
77
128
  });
129
+ if (input.sessionId) {
130
+ lines.push("## Reporting what you addressed");
131
+ lines.push("");
132
+ lines.push("When you resubmit the revised plan, list the annotation ids you acted on -");
133
+ lines.push("they are marked addressed for the reviewer and leave the open list:");
134
+ lines.push("");
135
+ lines.push("```sh");
136
+ lines.push(
137
+ `cueloop session submit-revision ${input.sessionId} --content-file ${path} --addressed <id,id,...>`,
138
+ );
139
+ lines.push("```");
140
+ lines.push("");
141
+ }
78
142
  }
79
143
 
80
- if (!diff && !input.annotations.length) {
144
+ if (!hasEdits && !annotations.length) {
81
145
  lines.push("_No edits or annotations._");
82
146
  lines.push("");
83
147
  }
84
148
  return lines.join("\n");
85
149
  }
86
150
 
87
- export function feedbackForSession(session: ReviewSession, verdictKind: VerdictKind, summary: string): string {
151
+ export function feedbackForSession(
152
+ session: ReviewSession,
153
+ verdictKind: VerdictKind,
154
+ summary: string,
155
+ ): string {
88
156
  return renderFeedback({
89
157
  verdictKind,
90
158
  summary,
91
159
  artifactContent: session.artifact.content,
92
160
  workingCopy: session.workingCopy,
161
+ artifactType: session.artifact.type,
93
162
  annotations: session.annotations,
94
- planPath: session.artifact.meta.planPath,
163
+ artifactPath: session.artifact.meta.prototypePath ?? session.artifact.meta.planPath,
164
+ sessionId: session.id,
95
165
  });
96
166
  }
97
167
 
98
- function capitalize(s: string): string {
99
- return s.charAt(0).toUpperCase() + s.slice(1);
168
+ function capitalize(word: string): string {
169
+ return word.charAt(0).toUpperCase() + word.slice(1);
100
170
  }
@@ -0,0 +1,92 @@
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
+ });
package/src/fuzzy.ts ADDED
@@ -0,0 +1,94 @@
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
+ /** Inclusive start offset into the haystack. */
12
+ start: number;
13
+ /** Exclusive end offset into the haystack. */
14
+ end: number;
15
+ /** Similarity of the matched window to the needle, in [0, 1]. */
16
+ similarity: number;
17
+ }
18
+
19
+ /**
20
+ * Levenshtein edit distance: the fewest single-character insertions, deletions,
21
+ * or substitutions that turn `left` into `right`. Uses two rolling rows, so it
22
+ * costs O(left.length * right.length) time and O(right.length) space.
23
+ */
24
+ export function levenshteinDistance(left: string, right: string): number {
25
+ if (left === right) return 0;
26
+ if (left.length === 0) return right.length;
27
+ if (right.length === 0) return left.length;
28
+
29
+ let previousRow: number[] = Array.from({ length: right.length + 1 }, (_unused, column) => column);
30
+ let currentRow: number[] = Array.from({ length: right.length + 1 }, () => 0);
31
+
32
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) {
33
+ currentRow[0] = leftIndex;
34
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
35
+ const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
36
+ currentRow[rightIndex] = Math.min(
37
+ currentRow[rightIndex - 1]! + 1, // insertion
38
+ previousRow[rightIndex]! + 1, // deletion
39
+ previousRow[rightIndex - 1]! + substitutionCost, // substitution
40
+ );
41
+ }
42
+ const swap = previousRow;
43
+ previousRow = currentRow;
44
+ currentRow = swap;
45
+ }
46
+
47
+ return previousRow[right.length]!;
48
+ }
49
+
50
+ /**
51
+ * Similarity of two strings in [0, 1]: 1 is identical, 0 shares nothing. Derived
52
+ * from the edit distance normalized by the longer string. Two empty strings are
53
+ * defined as identical (1).
54
+ */
55
+ export function similarityRatio(left: string, right: string): number {
56
+ const longestLength = Math.max(left.length, right.length);
57
+ if (longestLength === 0) return 1;
58
+ return 1 - levenshteinDistance(left, right) / longestLength;
59
+ }
60
+
61
+ /**
62
+ * Find the haystack window most similar to the needle, or null when nothing
63
+ * clears `minimumSimilarity`. Windows range around the needle length so the
64
+ * match tolerates a few inserted or deleted characters, not only substitutions.
65
+ * Ties keep the earliest window. Offsets index the haystack directly.
66
+ */
67
+ export function fuzzyFindBestMatch(
68
+ needle: string,
69
+ haystack: string,
70
+ minimumSimilarity: number,
71
+ ): FuzzyMatch | null {
72
+ if (needle === "" || haystack === "") return null;
73
+
74
+ const lengthTolerance = Math.max(2, Math.round(needle.length * 0.25));
75
+ const minimumWindowLength = Math.max(1, needle.length - lengthTolerance);
76
+ const maximumWindowLength = needle.length + lengthTolerance;
77
+
78
+ let bestMatch: FuzzyMatch | null = null;
79
+ for (let start = 0; start < haystack.length; start++) {
80
+ for (
81
+ let windowLength = minimumWindowLength;
82
+ windowLength <= maximumWindowLength && start + windowLength <= haystack.length;
83
+ windowLength++
84
+ ) {
85
+ const window = haystack.slice(start, start + windowLength);
86
+ const similarity = similarityRatio(needle, window);
87
+ if (similarity < minimumSimilarity) continue;
88
+ if (bestMatch === null || similarity > bestMatch.similarity) {
89
+ bestMatch = { start, end: start + windowLength, similarity };
90
+ }
91
+ }
92
+ }
93
+ return bestMatch;
94
+ }
@@ -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
+ });
package/src/herdr.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The herdr ambient env contract, single-sourced. A process running inside a
3
+ * herdr pane sees HERDR_ENV=1 plus the pane id; the herdr CLI itself reaches
4
+ * the app over HERDR_SOCKET_PATH (which the spawned `herdr` reads from the
5
+ * inherited env), so the binary is just `herdr` on PATH. Both the agent-side
6
+ * reporter (adapters) and the reviewer-side return-focus (client) read this
7
+ * contract; decoding it in one place stops the two sides drifting on which
8
+ * vars are required. Pure: no IO, env is only the default argument so callers
9
+ * can pass a fixture.
10
+ */
11
+
12
+ export interface HerdrContext {
13
+ paneId: string;
14
+ binPath: string;
15
+ }
16
+
17
+ export type HerdrEnv = Record<string, string | undefined>;
18
+
19
+ /**
20
+ * The pane's full contract - pane id and binary path - or null outside a
21
+ * functioning herdr. Required by anything that spawns the herdr CLI.
22
+ */
23
+ export function detectHerdr(env: HerdrEnv = process.env): HerdrContext | null {
24
+ if (env.HERDR_ENV !== "1") return null;
25
+ const paneId = env.HERDR_PANE_ID;
26
+ if (!paneId) return null;
27
+ // herdr 0.8.0 exposes no HERDR_BIN_PATH; the CLI is `herdr` on PATH and finds
28
+ // the app over HERDR_SOCKET_PATH from the inherited env. Tests still override
29
+ // HERDR_BIN_PATH to point the spawn at a stub script.
30
+ const binPath = env.HERDR_BIN_PATH ?? "herdr";
31
+ return { paneId, binPath };
32
+ }
33
+
34
+ /** True whenever this process runs inside a herdr pane. */
35
+ export function insideHerdr(env: HerdrEnv = process.env): boolean {
36
+ return env.HERDR_ENV === "1";
37
+ }
38
+
39
+ /**
40
+ * The pane a closing review should hand focus back to: the opener's
41
+ * CUELOOP_RETURN_PANE, or the session's recorded pane, but never this pane
42
+ * itself. Undefined outside herdr or when the only candidate is our own pane.
43
+ */
44
+ export function returnPaneFor(
45
+ sessionHerdrPane?: string,
46
+ env: HerdrEnv = process.env,
47
+ ): string | undefined {
48
+ if (!insideHerdr(env)) return undefined;
49
+ const pane = env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
50
+ return pane && pane !== env.HERDR_PANE_ID ? pane : undefined;
51
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export * from "./types";
2
+ export * from "./participants";
2
3
  export * from "./markdown";
4
+ export * from "./inline";
5
+ export * from "./working-copy";
3
6
  export * from "./diff";
4
7
  export * from "./anchor";
8
+ export * from "./fuzzy";
5
9
  export * from "./feedback";
10
+ export * from "./herdr";