@staix/agent-research 0.0.0-stage → 0.2.1
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/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +666 -2
- package/bin/ares.js +11 -0
- package/package.json +37 -4
- package/src/autonomy.ts +50 -0
- package/src/cli.ts +308 -0
- package/src/commands-table.ts +75 -0
- package/src/commands.ts +561 -0
- package/src/console/registry.ts +97 -0
- package/src/copilot.ts +91 -0
- package/src/exp/ablate.ts +140 -0
- package/src/exp/artifacts.ts +229 -0
- package/src/exp/attempts.ts +483 -0
- package/src/exp/executor.ts +207 -0
- package/src/exp/final.ts +197 -0
- package/src/exp/ideas.ts +117 -0
- package/src/exp/investigate.ts +187 -0
- package/src/exp/logs.ts +63 -0
- package/src/exp/metrics.ts +132 -0
- package/src/exp/protocol.ts +148 -0
- package/src/exp/selection.ts +53 -0
- package/src/exp/snapshot.ts +147 -0
- package/src/http.ts +106 -0
- package/src/index.ts +4 -0
- package/src/lit/bibtex.ts +61 -0
- package/src/lit/candidates.ts +148 -0
- package/src/lit/cards.ts +21 -0
- package/src/lit/cite.ts +26 -0
- package/src/lit/sources.ts +122 -0
- package/src/lit/verify.ts +110 -0
- package/src/lit/watch.ts +122 -0
- package/src/manifest.ts +39 -0
- package/src/mcp-tools.ts +210 -0
- package/src/mcp.ts +113 -0
- package/src/memory/amem.ts +97 -0
- package/src/memory/archive.ts +66 -0
- package/src/memory/lessons.ts +88 -0
- package/src/memory/skills.ts +99 -0
- package/src/paper/build.ts +73 -0
- package/src/paper/check.ts +181 -0
- package/src/paper/placeholders.ts +118 -0
- package/src/paper/review.ts +127 -0
- package/src/plan/controller.ts +222 -0
- package/src/plan/definition.ts +168 -0
- package/src/plan/hub.ts +56 -0
- package/src/plan/state.ts +270 -0
- package/src/playbook.ts +141 -0
- package/src/scope.ts +53 -0
- package/src/setup.ts +296 -0
- package/src/study-export.ts +254 -0
- package/src/study.ts +324 -0
- package/src/ui/panel.css +11 -0
- package/src/ui/panel.js +161 -0
- package/src/ui/plugin.ts +375 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { CopilotAcceptError } from "../copilot.ts";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { openStudy, readTextBounded, sha256 } from "../study.ts";
|
|
5
|
+
import { loadCandidates } from "../lit/candidates.ts";
|
|
6
|
+
import { checkCitations, citationOccurrences } from "../lit/cite.ts";
|
|
7
|
+
import { readProtocol } from "../exp/protocol.ts";
|
|
8
|
+
import { readSelection } from "../exp/selection.ts";
|
|
9
|
+
import { investigateResultPath, readClosedInvestigation } from "../exp/investigate.ts";
|
|
10
|
+
import { finalResultPath } from "../exp/final.ts";
|
|
11
|
+
import { MANUSCRIPT_BYTES, parsePlaceholders, resolvePlaceholders } from "./placeholders.ts";
|
|
12
|
+
|
|
13
|
+
export const MAX_CODE_NUMBERS = 500;
|
|
14
|
+
export interface PaperFinding { code: "bare-number" | "unresolved-ref" | "unaccepted-draft" | "bad-format" | "unknown-citation" | "unverified-citation" | "missing-negative-result"; start: number; end: number; line: number; column: number; message: string }
|
|
15
|
+
function blank(text: string, mask: Uint8Array): string { return text.split("").map((char, index) => mask[index] && char !== "\n" && char !== "\r" ? " " : char).join(""); }
|
|
16
|
+
function mark(mask: Uint8Array, start: number, end: number) { mask.fill(1, start, end); }
|
|
17
|
+
function escaped(text: string, index: number): boolean { let slashes = 0; while (index > 0 && text[--index] === "\\") slashes++; return slashes % 2 === 1; }
|
|
18
|
+
/** Masks syntax-only destinations and code while retaining original offsets. Visible link labels remain prose. */
|
|
19
|
+
function markdownMask(text: string, knownHidden?: Uint8Array): { mask: Uint8Array; code: Uint8Array; hidden: Uint8Array } {
|
|
20
|
+
const mask = new Uint8Array(text.length), protectedText = new Uint8Array(text.length), tokens = parsePlaceholders(text);
|
|
21
|
+
for (const token of [...tokens.placeholders, ...tokens.findings]) mark(protectedText, token.start, token.end);
|
|
22
|
+
let fence: { char: string; length: number } | undefined, paragraph = false, indented = false;
|
|
23
|
+
const listIndents: number[] = [];
|
|
24
|
+
for (const match of text.matchAll(/[^\n]*(?:\n|$)/g)) {
|
|
25
|
+
const line = match[0], start = match.index;
|
|
26
|
+
if (!line) continue;
|
|
27
|
+
if (!line.trim()) { paragraph = false; continue; }
|
|
28
|
+
if (!fence && knownHidden?.[start + /^[ \t]*/.exec(line)![0].length]) { paragraph = false; continue; }
|
|
29
|
+
if (fence) {
|
|
30
|
+
mark(mask, start, start + line.length);
|
|
31
|
+
const close = /^ {0,3}(`+|~+)[ \t]*\r?\n?$/.exec(line);
|
|
32
|
+
if (close && close[1]![0] === fence.char && close[1]!.length >= fence.length) fence = undefined;
|
|
33
|
+
paragraph = false; indented = false;
|
|
34
|
+
} else if (!protectedText[start]) {
|
|
35
|
+
const open = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)/.exec(line);
|
|
36
|
+
if (open && !(open[1]![0] === "`" && open[2]!.includes("`"))) { fence = { char: open[1]![0]!, length: open[1]!.length }; mark(mask, start, start + line.length); paragraph = false; indented = false; }
|
|
37
|
+
else {
|
|
38
|
+
const prefix = /^[ \t]*/.exec(line)![0];
|
|
39
|
+
let indent = 0; for (const char of prefix) indent += char === "\t" ? 4 - indent % 4 : 1;
|
|
40
|
+
const item = /^[ \t]*(?:[-+*]|[0-9]+[.)])[ \t]+/.exec(line);
|
|
41
|
+
let parent = listIndents.length; while (parent && listIndents[parent - 1]! > indent) parent--;
|
|
42
|
+
if (item && indent < (listIndents[parent - 1] ?? 0) + 4) {
|
|
43
|
+
listIndents.length = parent; listIndents.push(item[0].length); paragraph = true; indented = false;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
if (!paragraph) listIndents.length = parent;
|
|
47
|
+
if (indent >= (listIndents.at(-1) ?? 0) + 4 && (indented || !paragraph)) { mark(mask, start, start + line.length); indented = true; }
|
|
48
|
+
else { indented = false; paragraph = !/^ {0,3}(?:#{1,6}[ \t]|(?:---+|\*\*\*+|___+)[ \t]*$)/.test(line.trimEnd()); }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Backtick runs must have an exactly matching closer; unmatched delimiters remain prose.
|
|
54
|
+
const runs = [...text.matchAll(/`+/g)].filter(hit => !mask[hit.index] && !protectedText[hit.index] && !knownHidden?.[hit.index]);
|
|
55
|
+
const regions: number[] = []; let region = 0, previous = 0;
|
|
56
|
+
for (const hit of runs) {
|
|
57
|
+
if (/\n[ \t]*\r?\n/.test(text.slice(previous, hit.index)) || mask.subarray(previous, hit.index).includes(1)) region++;
|
|
58
|
+
regions.push(region); previous = hit.index + hit[0].length;
|
|
59
|
+
}
|
|
60
|
+
const next = new Map<number, number>(), closers = new Map<string, number>();
|
|
61
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
62
|
+
const hit = runs[i]!, key = `${regions[i]}:${hit[0].length}`;
|
|
63
|
+
if (closers.has(key)) next.set(i, closers.get(key)!); closers.set(key, i);
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < runs.length; i++) {
|
|
66
|
+
if (escaped(text, runs[i]!.index)) continue;
|
|
67
|
+
const close = next.get(i);
|
|
68
|
+
if (close !== undefined) { mark(mask, runs[i]!.index, runs[close]!.index + runs[close]![0].length); i = close; }
|
|
69
|
+
}
|
|
70
|
+
const code = mask.slice();
|
|
71
|
+
const labels: number[] = [];
|
|
72
|
+
for (let i = 0; i < text.length; i++) {
|
|
73
|
+
if (mask[i] || protectedText[i] || knownHidden?.[i] || (text[i] !== "<" && text[i] !== "]" && text[i] !== "[") || escaped(text, i)) continue;
|
|
74
|
+
if (text[i] === "[") { labels.push(i); continue; }
|
|
75
|
+
if (text[i] === "<") {
|
|
76
|
+
const end = text.indexOf(">", i + 1), target = text.slice(i + 1, end < 0 ? i + 1 : end);
|
|
77
|
+
if (end >= 0 && /^(?:[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*|[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)$/.test(target)) { mark(mask, i, end + 1); i = end; }
|
|
78
|
+
}
|
|
79
|
+
if (text[i] !== "]") continue;
|
|
80
|
+
const label = labels.pop();
|
|
81
|
+
if (label === undefined || text[i + 1] !== "(") continue;
|
|
82
|
+
let start = i + 2; while (/\s/.test(text[start] ?? "") && start < text.length) start++;
|
|
83
|
+
let end = start, depth = 0;
|
|
84
|
+
if (text[start] === "<") { end = text.indexOf(">", start + 1); if (end < 0) continue; end++; }
|
|
85
|
+
else {
|
|
86
|
+
while (end < text.length) {
|
|
87
|
+
const char = text[end]!;
|
|
88
|
+
if (char === "\\") { end += 2; continue; }
|
|
89
|
+
if (/\s/.test(char) || (char === ")" && depth === 0)) break;
|
|
90
|
+
if (char === "(") depth++;
|
|
91
|
+
if (char === ")") depth--;
|
|
92
|
+
end++;
|
|
93
|
+
}
|
|
94
|
+
if (depth) continue;
|
|
95
|
+
}
|
|
96
|
+
let tail = end; while (/\s/.test(text[tail] ?? "") && tail < text.length) tail++;
|
|
97
|
+
if (["\"", "'"].includes(text[tail] ?? "")) {
|
|
98
|
+
const quote = text[tail++]!; while (tail < text.length && (text[tail] !== quote || escaped(text, tail))) tail++;
|
|
99
|
+
if (tail >= text.length) continue; tail++; while (/\s/.test(text[tail] ?? "") && tail < text.length) tail++;
|
|
100
|
+
}
|
|
101
|
+
if (text[tail] === ")") mark(mask, start, end);
|
|
102
|
+
}
|
|
103
|
+
const hidden = new Uint8Array(text.length); let hasComment = false;
|
|
104
|
+
for (const comment of text.matchAll(/<!--[\s\S]*?(?:-->|$)/g)) {
|
|
105
|
+
if (!code[comment.index] && !protectedText[comment.index]) { mark(mask, comment.index, comment.index + comment[0].length); mark(hidden, comment.index, comment.index + comment[0].length); hasComment = true; }
|
|
106
|
+
}
|
|
107
|
+
// Reference definitions do not count as visible negative-result prose; labels/titles still get numeric checks.
|
|
108
|
+
for (const match of text.matchAll(/^ {0,3}\[[^\]\n]+\]:[ \t]*(<[^>\n]*>|[^\s]+)(?:[ \t]|$)/gm)) {
|
|
109
|
+
const target = match[1]!, start = match.index + match[0].lastIndexOf(target);
|
|
110
|
+
if (!mask[start] && !protectedText[start]) {
|
|
111
|
+
mark(mask, start, start + target.length);
|
|
112
|
+
const lineEnd = text.indexOf("\n", match.index); mark(hidden, match.index, lineEnd < 0 ? text.length : lineEnd);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Reclassify once with non-code comments excluded, so a fence inside a comment cannot hide later prose.
|
|
116
|
+
if (!knownHidden && hasComment) return markdownMask(text, hidden);
|
|
117
|
+
return { mask, code, hidden };
|
|
118
|
+
}
|
|
119
|
+
export function manuscriptPath(dir: string): string { return join(dir, "paper", "manuscript.md"); }
|
|
120
|
+
export function checkPaper(dirArg: string) {
|
|
121
|
+
const { dir, study } = openStudy(dirArg), file = manuscriptPath(dir);
|
|
122
|
+
let manuscript: string;
|
|
123
|
+
try { manuscript = readTextBounded(file, MANUSCRIPT_BYTES); }
|
|
124
|
+
catch { throw new Error("paper manuscript: missing, unreadable, invalid UTF-8, non-regular or larger than 2 MiB; restore paper/manuscript.md as a bounded UTF-8 regular file before paper check"); }
|
|
125
|
+
const masks = markdownMask(manuscript), prose = blank(manuscript, masks.mask), parsed = parsePlaceholders(prose), resolution = resolvePlaceholders(dir, prose);
|
|
126
|
+
const lines = [0]; for (let i = 0; i < manuscript.length; i++) if (manuscript[i] === "\n") lines.push(i + 1);
|
|
127
|
+
const location = (start: number) => {
|
|
128
|
+
let lo = 0, hi = lines.length; while (lo + 1 < hi) { const mid = (lo + hi) >>> 1; if (lines[mid]! <= start) lo = mid; else hi = mid; }
|
|
129
|
+
return { line: lo + 1, column: start - lines[lo]! + 1 };
|
|
130
|
+
};
|
|
131
|
+
const finding = (code: PaperFinding["code"], start: number, end: number, message: string): PaperFinding => ({ code, start, end, ...location(start), message });
|
|
132
|
+
const findings: PaperFinding[] = resolution.findings.map(f => finding(f.code, f.start, f.end, f.message));
|
|
133
|
+
const codeNumbers: { value: string; line: number; column: number }[] = [];
|
|
134
|
+
let codeNumberCount = 0;
|
|
135
|
+
const codeText = manuscript.split("").map((char, index) => masks.code[index] || char === "\n" || char === "\r" ? char : " ").join("");
|
|
136
|
+
for (const hit of codeText.matchAll(/\p{Nd}+(?:[.,]\p{Nd}+)*/gu)) {
|
|
137
|
+
codeNumberCount++;
|
|
138
|
+
if (codeNumbers.length < MAX_CODE_NUMBERS) codeNumbers.push({ value: hit[0], ...location(hit.index) });
|
|
139
|
+
}
|
|
140
|
+
const numberMask = masks.mask.slice(), citationMask = masks.mask.slice();
|
|
141
|
+
for (const placeholder of parsed.placeholders) {
|
|
142
|
+
mark(numberMask, placeholder.start, placeholder.end);
|
|
143
|
+
if (placeholder.kind === "number") mark(citationMask, placeholder.start, placeholder.end);
|
|
144
|
+
}
|
|
145
|
+
for (const problem of parsed.findings) { mark(numberMask, problem.start, problem.end); mark(citationMask, problem.start, problem.end); }
|
|
146
|
+
const citationText = blank(manuscript, citationMask), citations = checkCitations(citationText, loadCandidates(dir));
|
|
147
|
+
const groups = new Map<number, number>();
|
|
148
|
+
for (const hit of citationOccurrences(citationText)) {
|
|
149
|
+
mark(numberMask, hit.start, hit.end); groups.set(hit.groupStart, hit.groupEnd);
|
|
150
|
+
if (citations.unknown.includes(hit.key)) findings.push(finding("unknown-citation", hit.start, hit.end, `citation ${hit.key}: unknown key; add and verify the reference, then regenerate the bibliography`));
|
|
151
|
+
else if (citations.notVerified.includes(hit.key)) findings.push(finding("unverified-citation", hit.start, hit.end, `citation ${hit.key}: not included and identifier-verified; inspect ares lit list, verify the reference or exclude this citation`));
|
|
152
|
+
}
|
|
153
|
+
// Only locator-number syntax is exempt inside citation brackets; arbitrary prose numbers remain checked.
|
|
154
|
+
const locators = /(?:^|[^\p{L}\p{N}_])(p\.|pp\.|sec\.|§|ch\.|chap\.|fig\.|tbl\.|table|eq\.|l\.|ll\.|para\.)[ \t\r\n]*(\p{Nd}+(?:[ \t]*(?:--?|–|—)[ \t]*\p{Nd}+)?(?:[ \t]*,[ \t]*\p{Nd}+(?:[ \t]*(?:--?|–|—)[ \t]*\p{Nd}+)?)*)(?=$|[\s,;:\]\)]|\.(?!\p{Nd}))/giu;
|
|
155
|
+
for (const [start, end] of groups) {
|
|
156
|
+
const group = citationText.slice(start, end);
|
|
157
|
+
for (const hit of group.matchAll(locators)) {
|
|
158
|
+
const numbers = hit[2]!, offset = start + hit.index + hit[0].length - numbers.length;
|
|
159
|
+
mark(numberMask, offset, offset + numbers.length);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (study.mode === "experiment") {
|
|
163
|
+
for (const match of manuscript.matchAll(/^ {0,3}[0-9]+[.)](?=[ \t])/gm)) mark(numberMask, match.index, match.index + match[0].length);
|
|
164
|
+
for (const match of blank(manuscript, numberMask).matchAll(/\p{Nd}+(?:[.,]\p{Nd}+)*/gu)) findings.push(finding("bare-number", match.index, match.index + match[0].length, "prose number has no evidence ref; use {{n:REF}} for a measured value or {{lit:TEXT}} for a reviewer-visible literal"));
|
|
165
|
+
const requireRef = (ref: string) => {
|
|
166
|
+
if (!parsed.placeholders.some(p => p.kind === "number" && p.ref === ref && p.format === undefined && !masks.hidden[p.start])) findings.push(finding("missing-negative-result", 0, 0, `paper omits ${ref}; include {{n:${ref}}} in prose so the result cannot be hidden`));
|
|
167
|
+
};
|
|
168
|
+
if (existsSync(finalResultPath(dir))) requireRef("final.status");
|
|
169
|
+
try {
|
|
170
|
+
const protocol = readProtocol(dir), rule = protocol ? readSelection(dir, protocol) : undefined;
|
|
171
|
+
if (rule && existsSync(investigateResultPath(dir, rule.run))) {
|
|
172
|
+
try { if (readClosedInvestigation(dir).result.winner === null) requireRef("investigate.reason"); }
|
|
173
|
+
catch (error) { findings.push(error instanceof CopilotAcceptError ? finding("unaccepted-draft", 0, 0, error.message) : finding("unresolved-ref", 0, 0, "investigate result is not current and closed; run ares investigate close before checking the paper")); }
|
|
174
|
+
}
|
|
175
|
+
} catch (error) { findings.push(error instanceof CopilotAcceptError ? finding("unaccepted-draft", 0, 0, error.message) : finding("unresolved-ref", 0, 0, "experiment design or selection is invalid; restore the current validated inputs before paper check")); }
|
|
176
|
+
}
|
|
177
|
+
let rendered = "", cursor = 0;
|
|
178
|
+
for (const entry of resolution.placeholders) { rendered += manuscript.slice(cursor, entry.placeholder.start) + entry.rendered; cursor = entry.placeholder.end; }
|
|
179
|
+
rendered += manuscript.slice(cursor);
|
|
180
|
+
return { ok: findings.length === 0, manuscriptSha256: sha256(manuscript), rendered, placeholders: resolution.placeholders, findings: findings.sort((a, b) => a.start - b.start || a.code.localeCompare(b.code)), literals: resolution.literals, codeNumbers, codeNumberCount, citations };
|
|
181
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { CopilotAcceptError } from "../copilot.ts";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { openStudy, plain, readLedger, readTextBounded, sha256 } from "../study.ts";
|
|
4
|
+
import { ATTEMPT_BYTES, listAttempts, type Attempt, type AttemptStatus } from "../exp/attempts.ts";
|
|
5
|
+
import { protocolHash, readProtocol } from "../exp/protocol.ts";
|
|
6
|
+
import { INVESTIGATE_BYTES, investigateResultPath, metricValue, readClosedInvestigation } from "../exp/investigate.ts";
|
|
7
|
+
import { ABLATE_BYTES, ablateResultPath, readClosedAblate } from "../exp/ablate.ts";
|
|
8
|
+
import { FINAL_RESULT_BYTES, finalResultPath, readClosedFinal, requirePosthocClosed } from "../exp/final.ts";
|
|
9
|
+
|
|
10
|
+
export const MANUSCRIPT_BYTES = 2 * 1024 * 1024, MAX_PLACEHOLDERS = 5000;
|
|
11
|
+
interface Position { start: number; end: number; line: number; column: number; raw: string }
|
|
12
|
+
export type Placeholder = Position & ({ kind: "number"; ref: string; format?: number } | { kind: "literal"; text: string });
|
|
13
|
+
export interface PlaceholderFinding extends Position { code: "bad-format" | "unresolved-ref" | "unaccepted-draft"; message: string }
|
|
14
|
+
export interface ResolvedPlaceholder { placeholder: Placeholder; value: number | string; rendered: string; source?: { file: string; sha256: string } }
|
|
15
|
+
/** Offsets are UTF-16 string indices; line and column are 1-based, as used by the Markdown checker. */
|
|
16
|
+
export function parsePlaceholders(text: string): { placeholders: Placeholder[]; findings: PlaceholderFinding[] } {
|
|
17
|
+
if (Buffer.byteLength(text) > MANUSCRIPT_BYTES) throw new Error("paper manuscript: larger than 2 MiB; shorten paper/manuscript.md before checking or building");
|
|
18
|
+
const placeholders: Placeholder[] = [], findings: PlaceholderFinding[] = [];
|
|
19
|
+
const opening = /{{(?:n|lit):/g;
|
|
20
|
+
let match: RegExpExecArray | null, count = 0, scanned = 0, line = 1, column = 1;
|
|
21
|
+
while ((match = opening.exec(text))) {
|
|
22
|
+
const start = match.index, closing = text.indexOf("}}", opening.lastIndex), end = closing < 0 ? text.length : closing + 2;
|
|
23
|
+
while (scanned < start) { if (text[scanned++] === "\n") { line++; column = 1; } else column++; }
|
|
24
|
+
const at: Position = { start, end, line, column, raw: text.slice(start, end) };
|
|
25
|
+
if (++count > MAX_PLACEHOLDERS) { findings.push({ ...at, code: "bad-format", message: "paper placeholders: more than 5000; reduce the number of n/lit placeholders before building" }); break; }
|
|
26
|
+
const body = text.slice(opening.lastIndex, closing < 0 ? text.length : closing);
|
|
27
|
+
if (closing < 0 || body.includes("{{") || body.includes("}}")) {
|
|
28
|
+
findings.push({ ...at, code: "bad-format", message: "placeholder: missing closing braces or nested placeholder; use {{n:REF[|.Nf]}} or {{lit:TEXT}}" });
|
|
29
|
+
opening.lastIndex = end; continue;
|
|
30
|
+
}
|
|
31
|
+
opening.lastIndex = end;
|
|
32
|
+
if (match[0] === "{{lit:") {
|
|
33
|
+
if (!body.length) findings.push({ ...at, code: "bad-format", message: "literal placeholder: empty TEXT; supply the literal text for reviewer inspection" });
|
|
34
|
+
else placeholders.push({ ...at, kind: "literal", text: body });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// ponytail: metric names containing | or } cannot be represented by this grammar; rename them in a new protocol.
|
|
38
|
+
const parsed = /^([^{}|]+)(?:\|\.([0-6])f)?$/.exec(body);
|
|
39
|
+
if (!parsed || (/\s/.test(parsed[1]!) && !/^(attempt|posthoc):[a-f0-9]{16}\..+$/s.test(parsed[1]!))) findings.push({ ...at, code: "bad-format", message: "number placeholder: invalid ref or format; use {{n:REF}} or {{n:REF|.Nf}} with N from 0 to 6" });
|
|
40
|
+
else placeholders.push({ ...at, kind: "number", ref: parsed[1]!, ...(parsed[2] === undefined ? {} : { format: Number(parsed[2]) }) });
|
|
41
|
+
}
|
|
42
|
+
return { placeholders, findings };
|
|
43
|
+
}
|
|
44
|
+
function canonical(value: unknown): string {
|
|
45
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
46
|
+
if (plain(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
47
|
+
return JSON.stringify(value) ?? "null";
|
|
48
|
+
}
|
|
49
|
+
interface Source<T> { value: T; source: { file: string; sha256: string } }
|
|
50
|
+
function source<T>(dir: string, file: string, bound: number, value: T, expected?: string): Source<T> {
|
|
51
|
+
const text = readTextBounded(file, bound), hash = sha256(text);
|
|
52
|
+
if (expected === undefined && canonical(JSON.parse(text)) !== canonical(value)) throw new Error("source value changed during resolution");
|
|
53
|
+
if (expected !== undefined && hash !== expected) throw new Error("source bytes changed during resolution");
|
|
54
|
+
return { value, source: { file: relative(dir, file), sha256: hash } };
|
|
55
|
+
}
|
|
56
|
+
const statuses: readonly string[] = ["running", "succeeded", "failed", "cancelled", "unknown"];
|
|
57
|
+
function validValue(value: unknown): value is string | number { return typeof value === "string" ? value.length > 0 : typeof value === "number" && Number.isFinite(value); }
|
|
58
|
+
/** A per-call cache keeps repeated placeholders tied to the same checked source bytes. No writes, network or process execution. */
|
|
59
|
+
export function resolvePlaceholders(dirArg: string, text: string) {
|
|
60
|
+
const { dir, study } = openStudy(dirArg), parsed = parsePlaceholders(text), findings = [...parsed.findings], resolved: ResolvedPlaceholder[] = [], literals: Placeholder[] = [];
|
|
61
|
+
const cache = new Map<string, Source<unknown> | Error>();
|
|
62
|
+
const cached = <T>(key: string, read: () => Source<T>): Source<T> => {
|
|
63
|
+
if (!cache.has(key)) { try { cache.set(key, read()); } catch (error) { cache.set(key, error instanceof CopilotAcceptError ? error : new Error("absent, unclosed, stale or invalid source")); } }
|
|
64
|
+
const value = cache.get(key)!; if (value instanceof Error) throw value; return value as Source<T>;
|
|
65
|
+
};
|
|
66
|
+
const investigate = () => cached("investigate", () => { const closed = readClosedInvestigation(dir); return source(dir, investigateResultPath(dir, closed.result.run), INVESTIGATE_BYTES, closed.result, closed.sha256); });
|
|
67
|
+
const ablate = () => cached("ablate", () => { const closed = readClosedAblate(dir); return source(dir, ablateResultPath(dir), ABLATE_BYTES, closed.result, closed.sha256); });
|
|
68
|
+
const final = () => cached("final", () => { const closed = readClosedFinal(dir); return source(dir, finalResultPath(dir), FINAL_RESULT_BYTES, closed.result, closed.sha256); });
|
|
69
|
+
let attemptIndex: Map<string, Attempt[]> | undefined;
|
|
70
|
+
const attempt = (id: string, posthoc: boolean): Source<Attempt> => cached(`attempt:${id}:${posthoc}`, () => {
|
|
71
|
+
if (!attemptIndex) {
|
|
72
|
+
attemptIndex = new Map();
|
|
73
|
+
for (const record of listAttempts(dir).attempts) { const same = attemptIndex.get(record.id) ?? []; same.push(record); attemptIndex.set(record.id, same); }
|
|
74
|
+
}
|
|
75
|
+
const found = attemptIndex.get(id) ?? [];
|
|
76
|
+
if (found.length !== 1) throw new Error("attempt missing or ambiguous");
|
|
77
|
+
const a = found[0]!, protocol = readProtocol(dir);
|
|
78
|
+
if (posthoc) requirePosthocClosed(dir);
|
|
79
|
+
if (a.studyId !== study.id || a.status !== "succeeded" || !protocol || a.protocolHash !== protocolHash(protocol) || a.protocolHash !== protocolHash(a.protocol) || (a.run === "posthoc") !== posthoc) throw new Error("attempt is not a current completed metric source");
|
|
80
|
+
if (!readLedger(dir).some(e => e.type === "attempt.started" && e.data.id === a.id && e.data.run === a.run && e.data.commit === a.commit && e.data.seed === a.seed)) throw new Error("attempt start receipt missing");
|
|
81
|
+
return source(dir, join(dir, "runs", a.run, "attempts", `${a.id}.json`), ATTEMPT_BYTES, a);
|
|
82
|
+
});
|
|
83
|
+
for (const placeholder of parsed.placeholders) {
|
|
84
|
+
if (placeholder.kind === "literal") { literals.push(placeholder); resolved.push({ placeholder, value: placeholder.text, rendered: placeholder.text }); continue; }
|
|
85
|
+
const ref = placeholder.ref;
|
|
86
|
+
try {
|
|
87
|
+
if (study.mode !== "experiment") throw new Error("experiment refs unavailable in survey");
|
|
88
|
+
let value: unknown, provenance: Source<unknown>, exploratory = false;
|
|
89
|
+
let match: RegExpExecArray | null;
|
|
90
|
+
if ((match = /^final\.(status|reason|delta|mean|baselineMean)$/.exec(ref))) { const read = final(); provenance = read; value = read.value[match[1] as "status"]; }
|
|
91
|
+
else if ((match = /^final\.attempts\.([a-z]+)$/.exec(ref)) && statuses.includes(match[1]!)) { const read = final(); provenance = read; value = read.value.attempts.filter(a => a.status === match![1]).length; }
|
|
92
|
+
else if ((match = /^investigate\.(winner|reason)$/.exec(ref))) { const read = investigate(); provenance = read; value = match[1] === "winner" ? read.value.winner?.commit.slice(0, 12) : read.value.reason; }
|
|
93
|
+
else if ((match = /^investigate\.winner\.(delta|mean)$/.exec(ref))) { const read = investigate(); provenance = read; value = read.value.winner?.[match[1] as "delta"]; }
|
|
94
|
+
else if (ref === "investigate.baseline.mean") { const read = investigate(); provenance = read; value = read.value.baseline.mean; }
|
|
95
|
+
else if ((match = /^(investigate|ablate)\.totals\.([a-z]+)$/.exec(ref)) && statuses.includes(match[2]!)) { const read = match[1] === "investigate" ? investigate() : ablate(); provenance = read; value = read.value.totals.byStatus[match[2] as AttemptStatus]; }
|
|
96
|
+
else if ((match = /^ablate:([A-Za-z0-9][A-Za-z0-9_-]{0,63})\.(delta|mean|referenceMean)$/.exec(ref))) { const read = ablate(); provenance = read; value = read.value.ablations.find(a => a.id === match![1])?.paired[match[2] as "delta"]; }
|
|
97
|
+
else if ((match = /^(attempt|posthoc):([a-f0-9]{16})\.(.+)$/.exec(ref))) {
|
|
98
|
+
exploratory = match[1] === "posthoc"; const read = attempt(match[2]!, exploratory); provenance = read;
|
|
99
|
+
const metric = read.value.protocol.metrics?.find(m => m.name === match![3]);
|
|
100
|
+
if (!metric) throw new Error("metric undeclared"); value = metricValue(read.value, metric);
|
|
101
|
+
} else throw new Error("unknown reference");
|
|
102
|
+
if (!validValue(value)) throw new Error("value unavailable or non-finite");
|
|
103
|
+
if (typeof value === "string" && placeholder.format !== undefined) {
|
|
104
|
+
findings.push({ ...placeholder, code: "bad-format", message: `placeholder ${ref}: text refs take no numeric format; remove |.Nf` }); continue;
|
|
105
|
+
}
|
|
106
|
+
const rendered = (typeof value === "number" && placeholder.format !== undefined ? value.toFixed(placeholder.format) : String(value)) + (exploratory ? " (exploratory)" : "");
|
|
107
|
+
resolved.push({ placeholder, value, rendered, source: provenance.source });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof CopilotAcceptError) { findings.push({ ...placeholder, code: "unaccepted-draft", message: error.message }); continue; }
|
|
110
|
+
findings.push({ ...placeholder, code: "unresolved-ref", message: `placeholder ${ref}: unknown ref or absent, unclosed, stale or invalid source; inspect the attempt and close the current phase results before retrying` });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const replacements = new Map(resolved.map(entry => [entry.placeholder.start, entry]));
|
|
114
|
+
let rendered = "", cursor = 0;
|
|
115
|
+
for (const placeholder of parsed.placeholders) { rendered += text.slice(cursor, placeholder.start) + (replacements.get(placeholder.start)?.rendered ?? placeholder.raw); cursor = placeholder.end; }
|
|
116
|
+
rendered += text.slice(cursor);
|
|
117
|
+
return { rendered, placeholders: resolved, findings: findings.sort((a, b) => a.start - b.start), literals };
|
|
118
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { requireCopilotAccept, CopilotAcceptError } from "../copilot.ts";
|
|
2
|
+
import { boundedText, commit, onlyKeys, openStudy, plain, readLedger, sha256, type Actor, type LedgerEvent } from "../study.ts";
|
|
3
|
+
import { readCurrentBuild } from "./build.ts";
|
|
4
|
+
import { checkPaper } from "./check.ts";
|
|
5
|
+
export const REVIEW_KINDS = ["contradiction", "unsupported-claim", "number", "citation", "method", "clarity", "other"] as const;
|
|
6
|
+
export interface ReviewReport {
|
|
7
|
+
v: 1; reviewer: { id: string; modelFamily: string }; buildSha256: string;
|
|
8
|
+
checklist: { negativeResults: boolean; denominators: boolean; limitations: boolean; reproducibility: boolean };
|
|
9
|
+
findings: { id: string; kind: typeof REVIEW_KINDS[number]; location: string; text: string; blocking: boolean }[];
|
|
10
|
+
}
|
|
11
|
+
interface Resolution { reason: string; actor: Actor; at: string }
|
|
12
|
+
interface ReviewRecord { id: string; report: ReviewReport; actor: Actor; at: string; resolutions: Record<string, Resolution> }
|
|
13
|
+
function bad(message: string): never { throw new Error(`review: ${message}; inspect ares review show, correct the report or target, and retry`); }
|
|
14
|
+
function text(raw: unknown, field: string, max = 4000): string {
|
|
15
|
+
let value: string;
|
|
16
|
+
try { value = boundedText(raw, `review ${field}`, max); } catch { bad(`${field} must be nonblank text of at most ${max} bytes`); }
|
|
17
|
+
if (Buffer.byteLength(value) > max) bad(`${field} exceeds ${max} bytes`);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function identifier(raw: unknown, field: string): string { const value = text(raw, field, 128); if (value.includes("/")) bad(`${field} contains /; choose an id without slashes`); return value; }
|
|
21
|
+
function object(raw: unknown, keys: string[], field: string, strict: boolean): Record<string, unknown> {
|
|
22
|
+
if (!plain(raw)) bad(`${field} must be an object`);
|
|
23
|
+
if (strict) onlyKeys(raw, keys, `review ${field}`);
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
export function validateReview(raw: unknown, strict = true): ReviewReport {
|
|
27
|
+
try {
|
|
28
|
+
const value = object(raw, ["v", "reviewer", "buildSha256", "checklist", "findings"], "report", strict);
|
|
29
|
+
if (value.v !== 1) throw new Error("review: unsupported report version; use an ares version that supports this report, keeping the original bytes unchanged");
|
|
30
|
+
const reviewer = object(value.reviewer, ["id", "modelFamily"], "reviewer", strict), checklist = object(value.checklist, ["negativeResults", "denominators", "limitations", "reproducibility"], "checklist", strict);
|
|
31
|
+
const buildSha256 = text(value.buildSha256, "buildSha256", 64);
|
|
32
|
+
if (!/^[a-f0-9]{64}$/.test(buildSha256)) bad("buildSha256 must be the 64-hex current evidence hash; run ares paper build");
|
|
33
|
+
for (const name of ["negativeResults", "denominators", "limitations", "reproducibility"]) if (typeof checklist[name] !== "boolean") bad(`checklist.${name} must be a boolean`);
|
|
34
|
+
if (!Array.isArray(value.findings) || value.findings.length > 200) bad("findings must be an array of at most 200 entries");
|
|
35
|
+
const ids = new Set<string>();
|
|
36
|
+
const findings = value.findings.map((raw, index) => {
|
|
37
|
+
const finding = object(raw, ["id", "kind", "location", "text", "blocking"], `finding[${index}]`, strict), id = identifier(finding.id, `finding[${index}].id`);
|
|
38
|
+
if (ids.has(id)) bad(`duplicate finding id ${id}`); ids.add(id);
|
|
39
|
+
if (!REVIEW_KINDS.includes(finding.kind as typeof REVIEW_KINDS[number])) bad(`finding ${id} has unknown kind; use ${REVIEW_KINDS.join(", ")}`);
|
|
40
|
+
if (typeof finding.blocking !== "boolean") bad(`finding ${id}.blocking must be a boolean`);
|
|
41
|
+
return { id, kind: finding.kind as typeof REVIEW_KINDS[number], location: text(finding.location, `finding ${id}.location`), text: text(finding.text, `finding ${id}.text`), blocking: finding.kind === "contradiction" || finding.blocking };
|
|
42
|
+
});
|
|
43
|
+
return { v: 1, reviewer: { id: text(reviewer.id, "reviewer.id", 128), modelFamily: text(reviewer.modelFamily, "reviewer.modelFamily", 128).trim().toLowerCase() }, buildSha256, checklist: { negativeResults: checklist.negativeResults as boolean, denominators: checklist.denominators as boolean, limitations: checklist.limitations as boolean, reproducibility: checklist.reproducibility as boolean }, findings };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const message = error instanceof Error ? error.message : "invalid report";
|
|
46
|
+
if (message.includes("; inspect ares review show") || message.includes("supports this report")) throw error;
|
|
47
|
+
bad(`${message}; correct the input before ares review add --file PATH`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function foldReviews(events: LedgerEvent[]): ReviewRecord[] {
|
|
51
|
+
const rows: ReviewRecord[] = [], counts = new Map<string, number>();
|
|
52
|
+
for (const event of events) {
|
|
53
|
+
if (event.type === "review.added") {
|
|
54
|
+
const id = identifier(event.data.id, "stored review id"), report = validateReview(event.data.review, false), count = (counts.get(report.buildSha256) ?? 0) + 1;
|
|
55
|
+
if (count > 100 || rows.some(row => row.id === id)) bad("review journal has duplicate ids or more than 100 reviews for one build; restore its last valid version");
|
|
56
|
+
counts.set(report.buildSha256, count); rows.push({ id, report, actor: event.actor, at: event.at, resolutions: Object.create(null) as Record<string, Resolution> });
|
|
57
|
+
} else if (event.type === "review.resolved") {
|
|
58
|
+
const row = rows.find(row => row.id === event.data.reviewId), finding = row?.report.findings.find(f => f.id === event.data.findingId);
|
|
59
|
+
if (!row || !finding || event.data.buildSha256 !== row.report.buildSha256 || row.resolutions[finding.id]) bad("resolution journal references a missing or already resolved finding; restore its last valid version");
|
|
60
|
+
row.resolutions[finding.id] = { reason: text(event.data.reason, "stored resolution reason"), actor: event.actor, at: event.at };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return rows;
|
|
64
|
+
}
|
|
65
|
+
/** Caller holds the study lock; report ids are generated, findings never disappear by vote. */
|
|
66
|
+
export function addReview(dirArg: string, raw: unknown, actor: Actor) {
|
|
67
|
+
const { dir } = openStudy(dirArg);
|
|
68
|
+
requireCopilotAccept(dir, "paper");
|
|
69
|
+
const report = validateReview(raw), current = readCurrentBuild(dir), events = readLedger(dir), rows = foldReviews(events);
|
|
70
|
+
if (report.buildSha256 !== current.evidenceSha256) bad("stale buildSha256; use the evidenceSha256 from the current ares paper build output");
|
|
71
|
+
if (rows.filter(row => row.report.buildSha256 === report.buildSha256).length >= 100) bad("100 reviews already recorded for this build; resolve existing findings or build a revised manuscript");
|
|
72
|
+
const id = sha256(JSON.stringify([report, events.length])).slice(0, 16);
|
|
73
|
+
if (rows.some(row => row.id === id)) bad("review id collision; inspect the journal before retrying");
|
|
74
|
+
// ponytail: the ledger's existing 64 KiB event limit is stricter than individual text/count limits combined.
|
|
75
|
+
if (Buffer.byteLength(JSON.stringify(report)) > 60 * 1024) bad("combined report exceeds the 60 KiB review payload ceiling; shorten texts or submit fewer findings per report");
|
|
76
|
+
commit(dir, [], [{ actor, type: "review.added", data: { id, review: report } }]); return { id, ...report };
|
|
77
|
+
}
|
|
78
|
+
export function resolveReview(dirArg: string, target: string, reason: string, actor: Actor) {
|
|
79
|
+
const { dir } = openStudy(dirArg);
|
|
80
|
+
requireCopilotAccept(dir, "paper");
|
|
81
|
+
const parts = target.split("/");
|
|
82
|
+
if (parts.length !== 2) bad("resolution target must be <review-id>/<finding-id>; use ids from ares review show");
|
|
83
|
+
const reviewId = identifier(parts[0], "review id"), findingId = identifier(parts[1], "finding id"), why = text(reason, "resolution reason"), row = foldReviews(readLedger(dir)).find(row => row.id === reviewId);
|
|
84
|
+
if (!row) bad(`unknown review ${reviewId}`);
|
|
85
|
+
if (!row.report.findings.some(f => f.id === findingId)) bad(`unknown finding ${reviewId}/${findingId}`);
|
|
86
|
+
if (row.resolutions[findingId]) bad(`finding ${reviewId}/${findingId} is already resolved`);
|
|
87
|
+
commit(dir, [], [{ actor, type: "review.resolved", data: { reviewId, findingId, buildSha256: row.report.buildSha256, reason: why } }]);
|
|
88
|
+
return { reviewId, findingId, reason: why, actor };
|
|
89
|
+
}
|
|
90
|
+
function canonical(raw: unknown): string {
|
|
91
|
+
if (Array.isArray(raw)) return `[${raw.map(canonical).join(",")}]`;
|
|
92
|
+
if (plain(raw)) return `{${Object.keys(raw).sort().map(key => `${JSON.stringify(key)}:${canonical(raw[key])}`).join(",")}}`;
|
|
93
|
+
return JSON.stringify(raw) ?? "null";
|
|
94
|
+
}
|
|
95
|
+
function binding(current: { manuscriptSha256: string; evidenceSha256: string }, events: LedgerEvent[], rows: ReviewRecord[]): string {
|
|
96
|
+
const selected = rows.filter(row => row.report.buildSha256 === current.evidenceSha256), ids = new Set(selected.map(row => row.id));
|
|
97
|
+
const added = events.filter(e => e.type === "review.added" && ids.has(e.data.id as string)).map(e => sha256(canonical(e))).sort();
|
|
98
|
+
const resolutions = events.filter(e => e.type === "review.resolved" && ids.has(e.data.reviewId as string)).map(e => sha256(canonical(e))).sort();
|
|
99
|
+
return sha256(JSON.stringify([current.manuscriptSha256, current.evidenceSha256, added, resolutions]));
|
|
100
|
+
}
|
|
101
|
+
export function reviewBinding(dirArg: string): string | undefined {
|
|
102
|
+
try { const { dir } = openStudy(dirArg), current = readCurrentBuild(dir), events = readLedger(dir); return binding(current, events, foldReviews(events)); } catch { return undefined; }
|
|
103
|
+
}
|
|
104
|
+
export function reviewReadiness(dirArg: string) {
|
|
105
|
+
const { dir } = openStudy(dirArg), events = readLedger(dir), unmet: { code: string; message: string }[] = [];
|
|
106
|
+
for (const artifact of ["paper", "reviews"] as const) {
|
|
107
|
+
try { requireCopilotAccept(dir, artifact); }
|
|
108
|
+
catch (error) { unmet.push({ code: `accept:${artifact}`, message: error instanceof Error ? error.message : `copilot ${artifact} acceptance unavailable; inspect ares gate list` }); }
|
|
109
|
+
}
|
|
110
|
+
let current: ReturnType<typeof readCurrentBuild> | undefined, check: ReturnType<typeof checkPaper> | undefined;
|
|
111
|
+
try { current = readCurrentBuild(dir); check = current.check; } catch (error) { unmet.push({ code: "build", message: error instanceof Error ? error.message : "current build unavailable; run ares paper build" }); }
|
|
112
|
+
if (!check) { try { check = checkPaper(dir); } catch (error) { unmet.push({ code: "paper-check", message: error instanceof Error ? error.message : "paper check unavailable; restore paper/manuscript.md" }); } }
|
|
113
|
+
for (const finding of check?.findings ?? []) if (finding.code === "unaccepted-draft" && !unmet.some(item => item.code === "accept-source" && item.message === finding.message)) unmet.unshift({ code: "accept-source", message: finding.message });
|
|
114
|
+
if (check && !check.ok) unmet.push({ code: "paper-check", message: `paper check has ${check.findings.length} finding(s); run ares paper check --json and fix every finding` });
|
|
115
|
+
let rows: ReviewRecord[] = [];
|
|
116
|
+
try { rows = foldReviews(events); } catch (error) { unmet.push({ code: "review-journal", message: error instanceof Error ? error.message : "review journal invalid; restore its valid events" }); }
|
|
117
|
+
const buildSha256 = current?.evidenceSha256, reviews = rows.filter(row => row.report.buildSha256 === buildSha256), families = [...new Set(reviews.map(row => row.report.reviewer.modelFamily))].sort();
|
|
118
|
+
if (families.length < 2) unmet.push({ code: "model-families", message: "fewer than two distinct model families reviewed the current evidence hash; add current-build reviews from two families" });
|
|
119
|
+
const openBlocking = reviews.flatMap(row => row.report.findings.filter(f => f.blocking && !row.resolutions[f.id]).map(f => ({ reviewId: row.id, findingId: f.id, kind: f.kind, location: f.location, text: f.text })));
|
|
120
|
+
if (openBlocking.length) unmet.push({ code: "blocking-findings", message: `open blocking findings: ${openBlocking.map(f => `${f.reviewId}/${f.findingId} (${f.kind})`).join(", ")}; resolve each with a reason using ares review resolve` });
|
|
121
|
+
return { ready: unmet.length === 0, buildSha256: buildSha256 ?? null, ...(current ? { manuscriptSha256: current.manuscriptSha256 } : {}), check: check ?? null, reviews, families, openBlocking, checklists: reviews.map(row => ({ reviewId: row.id, ...row.report.checklist })), claims: "unavailable (STAIxBWLB/agent-hub#931)", unmet };
|
|
122
|
+
}
|
|
123
|
+
export function requireReviewReady(dir: string): void {
|
|
124
|
+
const report = reviewReadiness(dir), accept = report.unmet.find(item => item.code.startsWith("accept:") || item.code === "accept-source");
|
|
125
|
+
if (accept) throw new CopilotAcceptError(accept.message);
|
|
126
|
+
if (!report.ready) throw new Error(`gate review: ${report.unmet.map(item => `${item.code}: ${item.message}`).join("; ")}; inspect ares review show before approval`);
|
|
127
|
+
}
|