skillfid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/package.json +39 -0
- package/src/baseline.js +85 -0
- package/src/calibration.js +32 -0
- package/src/cli.js +329 -0
- package/src/concurrency.js +77 -0
- package/src/copilot-sdk.js +137 -0
- package/src/corpus.js +112 -0
- package/src/dataset.js +486 -0
- package/src/diagnosis.js +40 -0
- package/src/evaluation.js +486 -0
- package/src/files.js +56 -0
- package/src/inventory.js +83 -0
- package/src/journal.js +193 -0
- package/src/json.js +26 -0
- package/src/judge.js +39 -0
- package/src/progress.js +223 -0
- package/src/prompts.js +42 -0
- package/src/questions.js +80 -0
- package/src/report.js +233 -0
- package/src/structured.js +35 -0
- package/src/verification.js +33 -0
- package/templates/evaluation-report.html +537 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { access } from 'node:fs/promises';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
|
|
7
|
+
import { buildDataset, loadDataset, recalibrateDataset } from './dataset.js';
|
|
8
|
+
import { evaluateBaseline, evaluateDataset } from './evaluation.js';
|
|
9
|
+
import { OperationJournal } from './journal.js';
|
|
10
|
+
import { createProgressReporter } from './progress.js';
|
|
11
|
+
import { generateEvaluationReport } from './report.js';
|
|
12
|
+
|
|
13
|
+
const VERSION = '0.6.0';
|
|
14
|
+
let activeProgressReporter;
|
|
15
|
+
|
|
16
|
+
const HELP = `skillfid: evaluate how faithfully agent skills apply their source documentation
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
skillfid dataset build --corpus ./corpus --json
|
|
20
|
+
skillfid dataset recalibrate --dataset ./datasets/ds_abc123 --json
|
|
21
|
+
skillfid dataset verify --dataset ./datasets/ds_abc123 --json
|
|
22
|
+
skillfid eval baseline --dataset ./datasets/ds_abc123 --json
|
|
23
|
+
skillfid eval run --dataset ./datasets/ds_abc123 --skill ./skill --json
|
|
24
|
+
skillfid eval report --run ./runs/run_abc123 --dataset ./datasets/ds_abc123
|
|
25
|
+
skillfid operation status --work-dir .work/eval --json
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
skillfid [--json] [--quiet] dataset build --corpus <dir> [options]
|
|
29
|
+
skillfid [--json] [--quiet] dataset recalibrate --dataset <dir> [options]
|
|
30
|
+
skillfid [--json] [--quiet] dataset verify --dataset <dir>
|
|
31
|
+
skillfid [--json] [--quiet] eval baseline --dataset <dir> [options]
|
|
32
|
+
skillfid [--json] [--quiet] eval run --dataset <dir> --skill <dir> [options]
|
|
33
|
+
skillfid [--json] [--quiet] eval report --run <dir> --dataset <dir> [options]
|
|
34
|
+
skillfid [global-options] operation status --work-dir <dir> [--operation-id <id>]
|
|
35
|
+
|
|
36
|
+
Global options:
|
|
37
|
+
--json Print a stable JSON object to stdout
|
|
38
|
+
--quiet Suppress progress on stderr
|
|
39
|
+
--agent Bounded agent progress (alias: --progress agent)
|
|
40
|
+
--progress <mode> auto, human, agent, json, or quiet (default: auto)
|
|
41
|
+
--progress-interval <sec> Agent snapshot interval (default: 30)
|
|
42
|
+
-h, --help Show help; valid after any command
|
|
43
|
+
--version Print the version
|
|
44
|
+
|
|
45
|
+
Build options:
|
|
46
|
+
--corpus <dir> Markdown corpus directory (required)
|
|
47
|
+
--output-dir <dir> Dataset output directory (default: datasets)
|
|
48
|
+
--work-dir <dir> Isolated working directory (default: .work/dataset)
|
|
49
|
+
--model <name> Copilot model (default: gpt-5.6-sol)
|
|
50
|
+
--judge-model <name> Oracle calibration judge (default: --model)
|
|
51
|
+
--reasoning-effort <name> Reasoning effort (default: medium)
|
|
52
|
+
--timeout <seconds> Per-Copilot-call timeout (default: 600)
|
|
53
|
+
--timeout-retries <count> Retries after a session timeout (default: 1)
|
|
54
|
+
--max-attempts <count> Attempts to repair malformed JSON (default: 3)
|
|
55
|
+
--clean-residual-passes <count> Consecutive clean inventory passes (default: 1)
|
|
56
|
+
--max-residual-passes <count> Maximum inventory/audit passes (default: 3)
|
|
57
|
+
--concurrency <count> Parallel Copilot calls (default: 10; no maximum)
|
|
58
|
+
--resume Reuse matching work from a prior attempt (default)
|
|
59
|
+
--fresh Start from scratch without deleting prior state
|
|
60
|
+
|
|
61
|
+
Recalibration options:
|
|
62
|
+
--dataset <dir> Published schema v6 dataset directory (required)
|
|
63
|
+
--output-dir <dir> Dataset output directory (default: datasets)
|
|
64
|
+
--work-dir <dir> Isolated working directory (default: .work/recalibrate)
|
|
65
|
+
--model <name> Oracle model (default: source dataset)
|
|
66
|
+
--judge-model <name> Calibration judge (default: source dataset)
|
|
67
|
+
--reasoning-effort <name> Reasoning effort (default: source dataset)
|
|
68
|
+
--timeout <seconds> Per-Copilot-call timeout (default: 600)
|
|
69
|
+
--timeout-retries <count> Retries after a session timeout (default: 1)
|
|
70
|
+
--max-attempts <count> Attempts to repair malformed JSON (default: 3)
|
|
71
|
+
--concurrency <count> Parallel Copilot calls (default: 10; no maximum)
|
|
72
|
+
--resume Reuse matching work from a prior attempt (default)
|
|
73
|
+
--fresh Start from scratch without deleting prior state
|
|
74
|
+
|
|
75
|
+
Baseline options:
|
|
76
|
+
--dataset <dir> Published schema v6 dataset directory (required)
|
|
77
|
+
--output-dir <dir> Baseline output directory (default: baselines)
|
|
78
|
+
--work-dir <dir> Isolated working directory (default: .work/baseline)
|
|
79
|
+
--model <name> Subject model (default: dataset calibration)
|
|
80
|
+
--judge-model <name> Judge model (default: dataset calibration)
|
|
81
|
+
--reasoning-effort <name> Reasoning effort (default: dataset calibration)
|
|
82
|
+
--trials <count> Trials per question (default: 3)
|
|
83
|
+
--timeout <seconds> Per-Copilot-call timeout (default: 600)
|
|
84
|
+
--timeout-retries <count> Retries after a session timeout (default: 1)
|
|
85
|
+
--concurrency <count> Parallel Copilot calls (default: 10; no maximum)
|
|
86
|
+
--resume Reuse matching work from a prior attempt (default)
|
|
87
|
+
--fresh Start from scratch without deleting prior state
|
|
88
|
+
|
|
89
|
+
Evaluation options:
|
|
90
|
+
--dataset <dir> Published schema v6 dataset directory (required)
|
|
91
|
+
--skill <dir> Skill directory containing SKILL.md (required)
|
|
92
|
+
--baseline-dir <dir> Reusable baseline directory (default: baselines)
|
|
93
|
+
--output-dir <dir> Run output directory (default: runs)
|
|
94
|
+
--work-dir <dir> Isolated working directory (default: .work/eval)
|
|
95
|
+
--model <name> Subject model (default: dataset calibration)
|
|
96
|
+
--judge-model <name> Judge model (default: dataset calibration)
|
|
97
|
+
--reasoning-effort <name> Reasoning effort (default: dataset calibration)
|
|
98
|
+
--skill-invocation <mode> Skill invocation: auto or explicit (default: auto)
|
|
99
|
+
--trials <count> Trials per question and condition (default: 3)
|
|
100
|
+
--timeout <seconds> Per-Copilot-call timeout (default: 600)
|
|
101
|
+
--timeout-retries <count> Retries after a session timeout (default: 1)
|
|
102
|
+
--concurrency <count> Parallel Copilot calls (default: 10; no maximum)
|
|
103
|
+
--resume Reuse matching work from a prior attempt (default)
|
|
104
|
+
--fresh Start from scratch without deleting prior state
|
|
105
|
+
|
|
106
|
+
Report options:
|
|
107
|
+
--run <dir> Completed evaluation run directory (required)
|
|
108
|
+
--dataset <dir> Dataset used by the evaluation run (required)
|
|
109
|
+
-o, --output <file> HTML output file (default: <run>/report.html)
|
|
110
|
+
--title <text> Report title (default: Skill evaluation)
|
|
111
|
+
|
|
112
|
+
Prerequisites:
|
|
113
|
+
Node.js 24+ and GitHub Copilot CLI authenticated for Copilot access.
|
|
114
|
+
|
|
115
|
+
JSON success schemas:
|
|
116
|
+
dataset build: {"datasetPath":string,"status":"created"}
|
|
117
|
+
dataset recalibrate: {"datasetPath":string,"sourceDatasetId":string,"status":"created"}
|
|
118
|
+
dataset verify: {"datasetId":string,"evidenceRecords":number,"questions":number,"valid":true}
|
|
119
|
+
eval baseline: {"baselinePath":string,"status":"completed"}
|
|
120
|
+
eval run: {"runPath":string,"status":"completed"}
|
|
121
|
+
eval report: {"reportPath":string,"status":"created"}
|
|
122
|
+
operation status: {"journalPath":string,"operations":[...]}
|
|
123
|
+
|
|
124
|
+
I/O contract:
|
|
125
|
+
Primary output goes to stdout. Progress and errors go to stderr.
|
|
126
|
+
|
|
127
|
+
Exit codes:
|
|
128
|
+
0 Success
|
|
129
|
+
1 Runtime, validation, authentication, or Copilot failure
|
|
130
|
+
2 Invalid command or option
|
|
131
|
+
130 Interrupted by Ctrl-C
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
const commonOptions = {
|
|
135
|
+
json: { type: 'boolean', default: false },
|
|
136
|
+
quiet: { type: 'boolean', default: false },
|
|
137
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
138
|
+
version: { type: 'boolean', default: false },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
142
|
+
if (argv.includes('--version')) { process.stdout.write(`${VERSION}\n`); return; }
|
|
143
|
+
if (!argv.length || argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') { process.stdout.write(HELP); return; }
|
|
144
|
+
const globals = extractGlobals(argv);
|
|
145
|
+
const progressReporter = createProgressReporter({ mode: globals.progressMode, intervalSeconds: globals.progressInterval });
|
|
146
|
+
activeProgressReporter = progressReporter;
|
|
147
|
+
const progress = (message) => progressReporter.report(message);
|
|
148
|
+
try {
|
|
149
|
+
return await runCommand(globals, progress);
|
|
150
|
+
} finally {
|
|
151
|
+
progressReporter.close();
|
|
152
|
+
if (activeProgressReporter === progressReporter) activeProgressReporter = undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function runCommand(globals, progress) {
|
|
157
|
+
const [group, command, ...commandArgs] = globals.args;
|
|
158
|
+
if (group === 'dataset' && command === 'build') {
|
|
159
|
+
const values = options(commandArgs, { corpus: { type: 'string' }, 'output-dir': stringOption('datasets'), 'work-dir': stringOption('.work/dataset'), model: stringOption('gpt-5.6-sol'), 'judge-model': { type: 'string' }, 'reasoning-effort': stringOption('medium'), timeout: stringOption('600'), 'timeout-retries': stringOption('1'), 'max-attempts': stringOption('3'), 'clean-residual-passes': stringOption('1'), 'max-residual-passes': stringOption('3'), concurrency: stringOption('10'), resume: { type: 'boolean' }, fresh: { type: 'boolean', default: false } });
|
|
160
|
+
required(values, 'corpus', 'dataset build');
|
|
161
|
+
const datasetPath = await buildDataset({ corpusPath: values.corpus, outputRoot: values['output-dir'], workRoot: values['work-dir'], options: { model: values.model, judgeModel: values['judge-model'] ?? values.model, reasoningEffort: values['reasoning-effort'], timeoutSeconds: positiveNumber(values.timeout, '--timeout'), timeoutRetries: nonNegativeInteger(values['timeout-retries'], '--timeout-retries'), maxAttempts: positiveInteger(values['max-attempts'], '--max-attempts'), cleanResidualPasses: positiveInteger(values['clean-residual-passes'], '--clean-residual-passes'), maxResidualPasses: positiveInteger(values['max-residual-passes'], '--max-residual-passes'), concurrency: positiveInteger(values.concurrency, '--concurrency'), resume: resolveResume(values) }, progress });
|
|
162
|
+
output({ datasetPath, status: 'created' }, globals.json, `Output: ${displayPath(datasetPath)}`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (group === 'dataset' && command === 'recalibrate') {
|
|
166
|
+
const values = options(commandArgs, { dataset: { type: 'string' }, 'output-dir': stringOption('datasets'), 'work-dir': stringOption('.work/recalibrate'), model: { type: 'string' }, 'judge-model': { type: 'string' }, 'reasoning-effort': { type: 'string' }, timeout: stringOption('600'), 'timeout-retries': stringOption('1'), 'max-attempts': stringOption('3'), concurrency: stringOption('10'), resume: { type: 'boolean' }, fresh: { type: 'boolean', default: false } });
|
|
167
|
+
required(values, 'dataset', 'dataset recalibrate');
|
|
168
|
+
const source = await loadDataset(values.dataset);
|
|
169
|
+
const datasetPath = await recalibrateDataset({ datasetPath: values.dataset, outputRoot: values['output-dir'], workRoot: values['work-dir'], options: { model: values.model, judgeModel: values['judge-model'] ?? values.model, reasoningEffort: values['reasoning-effort'], timeoutSeconds: positiveNumber(values.timeout, '--timeout'), timeoutRetries: nonNegativeInteger(values['timeout-retries'], '--timeout-retries'), maxAttempts: positiveInteger(values['max-attempts'], '--max-attempts'), concurrency: positiveInteger(values.concurrency, '--concurrency'), resume: resolveResume(values) }, progress });
|
|
170
|
+
output({ datasetPath, sourceDatasetId: source.datasetId, status: 'created' }, globals.json, `Output: ${displayPath(datasetPath)}`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (group === 'dataset' && command === 'verify') {
|
|
174
|
+
const values = options(commandArgs, { dataset: { type: 'string' } });
|
|
175
|
+
required(values, 'dataset', 'dataset verify');
|
|
176
|
+
progress({ type: 'start', workflow: 'verification', title: 'Verifying dataset', current: 'Reading manifest', progress: { done: 0, total: 7, label: 'integrity checks passed' } });
|
|
177
|
+
const dataset = await loadDataset(values.dataset, { progress: ({ done, total, current }) => progress({ type: 'update', workflow: 'verification', current, progress: { done, total, label: 'integrity checks passed' } }) });
|
|
178
|
+
progress({ type: 'complete', workflow: 'verification', title: 'Dataset verified', summary: [`${dataset.questions.length} question${dataset.questions.length === 1 ? '' : 's'}, ${dataset.evidence.length} evidence record${dataset.evidence.length === 1 ? '' : 's'}`] });
|
|
179
|
+
output({ datasetId: dataset.datasetId, evidenceRecords: dataset.evidence.length, questions: dataset.questions.length, valid: true }, globals.json, `Dataset is valid\n Questions: ${dataset.questions.length}\n Evidence records: ${dataset.evidence.length}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (group === 'operation' && command === 'status') {
|
|
183
|
+
const values = options(commandArgs, { 'work-dir': { type: 'string' }, 'operation-id': { type: 'string' } });
|
|
184
|
+
required(values, 'work-dir', 'operation status');
|
|
185
|
+
const journalPath = path.resolve(values['work-dir'], 'operations.sqlite');
|
|
186
|
+
progress({ type: 'start', workflow: 'status', title: 'Reading operation status', current: 'Opening operation journal', progress: { done: 0, total: 1, label: 'journals read' } });
|
|
187
|
+
try { await access(journalPath); }
|
|
188
|
+
catch { throw new Error(`Operation journal not found: ${journalPath}`); }
|
|
189
|
+
const journal = await OperationJournal.open(journalPath, { readOnly: true });
|
|
190
|
+
try {
|
|
191
|
+
const operations = (values['operation-id'] ? [journal.getOperation(values['operation-id'])].filter(Boolean) : journal.listOperations()).map((operation) => {
|
|
192
|
+
const jobs = journal.listJobs(operation.operationId);
|
|
193
|
+
const lastActivityAt = Math.max(operation.updatedAt, ...jobs.map((job) => job.updatedAt));
|
|
194
|
+
const counts = journal.jobCounts(operation.operationId);
|
|
195
|
+
const expiredLeases = jobs.filter((job) => job.status === 'running' && job.leaseExpiresAt <= Date.now()).length;
|
|
196
|
+
const health = operation.status === 'running' && counts.running > 0 && expiredLeases === counts.running ? 'stalled' : operation.status;
|
|
197
|
+
return { ...operation, jobs: counts, lastActivityAt, health };
|
|
198
|
+
});
|
|
199
|
+
if (values['operation-id'] && !operations.length) throw new Error(`Operation not found: ${values['operation-id']}`);
|
|
200
|
+
progress({ type: 'complete', workflow: 'status', title: 'Operation status ready', summary: [`${operations.length} operation${operations.length === 1 ? '' : 's'} found`] });
|
|
201
|
+
output({ journalPath, operations }, globals.json, formatOperationStatus(operations));
|
|
202
|
+
return;
|
|
203
|
+
} finally { journal.close(); }
|
|
204
|
+
}
|
|
205
|
+
if (group === 'eval' && command === 'report') {
|
|
206
|
+
const values = options(commandArgs, { run: { type: 'string' }, dataset: { type: 'string' }, output: { type: 'string', short: 'o' }, title: stringOption('Skill evaluation') });
|
|
207
|
+
required(values, 'run', 'eval report'); required(values, 'dataset', 'eval report');
|
|
208
|
+
progress({ type: 'start', workflow: 'report', title: 'Generating report', current: 'Reading evaluation artifacts', progress: { done: 0, total: 1, label: 'reports generated' } });
|
|
209
|
+
const reportPath = await generateEvaluationReport({ runPath: values.run, datasetPath: values.dataset, outputPath: values.output, title: values.title });
|
|
210
|
+
progress({ type: 'complete', workflow: 'report', title: 'Report generated', summary: [displayPath(reportPath)] });
|
|
211
|
+
output({ reportPath, status: 'created' }, globals.json, `Output: ${displayPath(reportPath)}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (group === 'eval' && command === 'baseline') {
|
|
215
|
+
const values = options(commandArgs, { dataset: { type: 'string' }, 'output-dir': stringOption('baselines'), 'work-dir': stringOption('.work/baseline'), model: { type: 'string' }, 'judge-model': { type: 'string' }, 'reasoning-effort': { type: 'string' }, trials: stringOption('3'), timeout: stringOption('600'), 'timeout-retries': stringOption('1'), concurrency: stringOption('10'), resume: { type: 'boolean' }, fresh: { type: 'boolean', default: false } });
|
|
216
|
+
required(values, 'dataset', 'eval baseline');
|
|
217
|
+
const baselinePath = await evaluateBaseline({ datasetPath: values.dataset, outputRoot: values['output-dir'], workRoot: values['work-dir'], options: { model: values.model, judgeModel: values['judge-model'], reasoningEffort: values['reasoning-effort'], trialsPerQuestion: positiveInteger(values.trials, '--trials'), timeoutSeconds: positiveNumber(values.timeout, '--timeout'), timeoutRetries: nonNegativeInteger(values['timeout-retries'], '--timeout-retries'), concurrency: positiveInteger(values.concurrency, '--concurrency'), resume: resolveResume(values) }, progress });
|
|
218
|
+
output({ baselinePath, status: 'completed' }, globals.json, `Output: ${displayPath(baselinePath)}`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (group === 'eval' && command === 'run') {
|
|
222
|
+
const values = options(commandArgs, { dataset: { type: 'string' }, skill: { type: 'string' }, 'baseline-dir': stringOption('baselines'), 'output-dir': stringOption('runs'), 'work-dir': stringOption('.work/eval'), model: { type: 'string' }, 'judge-model': { type: 'string' }, 'reasoning-effort': { type: 'string' }, 'skill-invocation': stringOption('auto'), trials: stringOption('3'), timeout: stringOption('600'), 'timeout-retries': stringOption('1'), concurrency: stringOption('10'), resume: { type: 'boolean' }, fresh: { type: 'boolean', default: false } });
|
|
223
|
+
required(values, 'dataset', 'eval run'); required(values, 'skill', 'eval run');
|
|
224
|
+
const runPath = await evaluateDataset({ datasetPath: values.dataset, skillPath: values.skill, baselineRoot: values['baseline-dir'], outputRoot: values['output-dir'], workRoot: values['work-dir'], options: { model: values.model, judgeModel: values['judge-model'], reasoningEffort: values['reasoning-effort'], skillInvocation: choice(values['skill-invocation'], '--skill-invocation', ['auto', 'explicit']), trialsPerQuestion: positiveInteger(values.trials, '--trials'), timeoutSeconds: positiveNumber(values.timeout, '--timeout'), timeoutRetries: nonNegativeInteger(values['timeout-retries'], '--timeout-retries'), concurrency: positiveInteger(values.concurrency, '--concurrency'), resume: resolveResume(values) }, progress });
|
|
225
|
+
output({ runPath, status: 'completed' }, globals.json, `Output: ${displayPath(runPath)}`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
throw new UsageError(`Unknown command: ${[group, command].filter(Boolean).join(' ') || '(none)'}. Valid commands: dataset build, dataset recalibrate, dataset verify, eval baseline, eval run, eval report, operation status.`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
class UsageError extends Error { constructor(message) { super(message); this.exitCode = 2; } }
|
|
232
|
+
|
|
233
|
+
function extractGlobals(argv) {
|
|
234
|
+
const args = [];
|
|
235
|
+
let json = false; let quiet = false; let agent = false; let progressMode; let progressInterval = 30;
|
|
236
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
237
|
+
const arg = argv[index];
|
|
238
|
+
if (arg === '--json') json = true;
|
|
239
|
+
else if (arg === '--quiet') quiet = true;
|
|
240
|
+
else if (arg === '--agent') agent = true;
|
|
241
|
+
else if (arg === '--progress' || arg === '--progress-interval') {
|
|
242
|
+
const value = argv[index + 1];
|
|
243
|
+
if (!value || value.startsWith('--')) throw new UsageError(`Option ${arg} requires a value.`);
|
|
244
|
+
index += 1;
|
|
245
|
+
if (arg === '--progress') progressMode = value;
|
|
246
|
+
else progressInterval = positiveNumber(value, '--progress-interval');
|
|
247
|
+
}
|
|
248
|
+
else if (arg.startsWith('--progress=')) progressMode = arg.slice('--progress='.length);
|
|
249
|
+
else if (arg.startsWith('--progress-interval=')) progressInterval = positiveNumber(arg.slice('--progress-interval='.length), '--progress-interval');
|
|
250
|
+
else args.push(arg);
|
|
251
|
+
}
|
|
252
|
+
if (!['auto', 'human', 'agent', 'json', 'quiet'].includes(progressMode ?? 'auto')) throw new UsageError(`Invalid --progress value ${JSON.stringify(progressMode)}. Valid values: auto, human, agent, json, quiet.`);
|
|
253
|
+
if (quiet && progressMode && progressMode !== 'quiet') throw new UsageError('--quiet conflicts with a non-quiet --progress mode.');
|
|
254
|
+
if (agent && progressMode && progressMode !== 'agent') throw new UsageError('--agent conflicts with a non-agent --progress mode.');
|
|
255
|
+
return { args, json, progressMode: quiet ? 'quiet' : agent ? 'agent' : progressMode ?? 'auto', progressInterval };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function options(args, commandOptions) {
|
|
259
|
+
try { return parseArgs({ args, options: { ...commonOptions, ...commandOptions }, strict: true, allowPositionals: false }).values; }
|
|
260
|
+
catch (error) { throw new UsageError(`${error.message}. Run 'skillfid --help' for valid options.`); }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function stringOption(defaultValue) { return { type: 'string', default: defaultValue }; }
|
|
264
|
+
function required(values, name, command) { if (!values[name]) throw new UsageError(`Missing required option --${name} for '${command}'.`); }
|
|
265
|
+
function positiveInteger(value, name) { const number = Number(value); if (!Number.isInteger(number) || number < 1) throw new UsageError(`Invalid ${name} value ${JSON.stringify(value)}. Expected a positive integer.`); return number; }
|
|
266
|
+
function nonNegativeInteger(value, name) { const number = Number(value); if (!Number.isInteger(number) || number < 0) throw new UsageError(`Invalid ${name} value ${JSON.stringify(value)}. Expected a non-negative integer.`); return number; }
|
|
267
|
+
function positiveNumber(value, name) { const number = Number(value); if (!Number.isFinite(number) || number <= 0) throw new UsageError(`Invalid ${name} value ${JSON.stringify(value)}. Expected a positive number.`); return number; }
|
|
268
|
+
function choice(value, name, validValues) { if (!validValues.includes(value)) throw new UsageError(`Invalid ${name} value ${JSON.stringify(value)}. Valid values: ${validValues.join(', ')}.`); return value; }
|
|
269
|
+
function resolveResume(values) { if (values.resume && values.fresh) throw new UsageError('Options --resume and --fresh cannot be used together.'); return !values.fresh; }
|
|
270
|
+
|
|
271
|
+
function output(value, json, plain = undefined) {
|
|
272
|
+
process.stdout.write(json ? `${JSON.stringify(value)}\n` : `${plain ?? JSON.stringify(value, null, 2)}\n`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function displayPath(value) {
|
|
276
|
+
const relative = path.relative(process.cwd(), value);
|
|
277
|
+
return relative && !relative.startsWith('..') ? relative : value;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function formatOperationStatus(operations) {
|
|
281
|
+
if (!operations.length) return 'No operations found.';
|
|
282
|
+
return ['Operations', ...operations.map((operation) => {
|
|
283
|
+
const jobs = operation.jobs;
|
|
284
|
+
const total = jobs.pending + jobs.running + jobs.completed + jobs.failed;
|
|
285
|
+
const activity = operation.health === 'completed' ? `finished ${formatAge(operation.updatedAt)}` : `last activity ${formatAge(operation.lastActivityAt)}`;
|
|
286
|
+
return ` ${operation.kind} ${operation.health} ${jobs.completed}/${total} jobs complete${jobs.failed ? `, ${jobs.failed} failed` : ''} · ${activity}`;
|
|
287
|
+
})].join('\n');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function formatAge(timestamp) {
|
|
291
|
+
const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
|
|
292
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
293
|
+
const minutes = Math.round(seconds / 60);
|
|
294
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
295
|
+
const hours = Math.round(minutes / 60);
|
|
296
|
+
return `${hours}h ago`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function resumeCommand(argv) {
|
|
300
|
+
if (!isResumableCommand(argv)) return undefined;
|
|
301
|
+
const args = argv.filter((arg) => arg !== '--fresh' && !arg.startsWith('--fresh='));
|
|
302
|
+
if (!args.includes('--resume')) args.push('--resume');
|
|
303
|
+
return `npm start -- ${args.map(shellArgument).join(' ')}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function interruptionMessage(argv) {
|
|
307
|
+
const command = resumeCommand(argv);
|
|
308
|
+
if (!command) return 'Interrupted.\n';
|
|
309
|
+
const title = argv.some((arg, index) => arg === 'dataset' && argv[index + 1] === 'recalibrate') ? 'Recalibration interrupted' : argv.some((arg, index) => arg === 'eval' && argv[index + 1] === 'baseline') ? 'Baseline interrupted' : argv.includes('dataset') ? 'Extraction interrupted' : 'Evaluation interrupted';
|
|
310
|
+
return `${title}\nAny completed work was saved.\n\nRun this command to continue:\n${command}\n`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isResumableCommand(argv) {
|
|
314
|
+
return argv.some((arg, index) => (arg === 'dataset' && ['build', 'recalibrate'].includes(argv[index + 1])) || (arg === 'eval' && ['baseline', 'run'].includes(argv[index + 1])));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function shellArgument(value) {
|
|
318
|
+
return /^[A-Za-z0-9_./:@=+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
322
|
+
let interrupted = false;
|
|
323
|
+
process.once('SIGINT', () => { interrupted = true; activeProgressReporter?.close(); process.stderr.write(interruptionMessage(process.argv.slice(2))); process.exitCode = 130; });
|
|
324
|
+
main().catch((error) => {
|
|
325
|
+
if (interrupted) return;
|
|
326
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
327
|
+
process.exitCode = error.exitCode ?? 1;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export class AsyncLimiter {
|
|
2
|
+
constructor(limit = 10, onChange = undefined) {
|
|
3
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error('Concurrency must be a positive integer');
|
|
4
|
+
if (onChange !== undefined && typeof onChange !== 'function') throw new Error('onChange must be a function');
|
|
5
|
+
this.limit = limit;
|
|
6
|
+
this.onChange = onChange;
|
|
7
|
+
this.active = 0;
|
|
8
|
+
this.queue = [];
|
|
9
|
+
this.completed = 0;
|
|
10
|
+
this.failed = 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get stats() {
|
|
14
|
+
return { limit: this.limit, active: this.active, queued: this.queue.length, completed: this.completed, failed: this.failed };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
run(task) {
|
|
18
|
+
if (typeof task !== 'function') throw new Error('Limited task must be a function');
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
this.queue.push({ task, resolve, reject });
|
|
21
|
+
this.#drain();
|
|
22
|
+
this.#notify();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#drain() {
|
|
27
|
+
while (this.active < this.limit && this.queue.length) {
|
|
28
|
+
const { task, resolve, reject } = this.queue.shift();
|
|
29
|
+
this.active += 1;
|
|
30
|
+
Promise.resolve().then(task).then((value) => {
|
|
31
|
+
this.completed += 1;
|
|
32
|
+
this.active -= 1;
|
|
33
|
+
this.#drain();
|
|
34
|
+
this.#notify();
|
|
35
|
+
resolve(value);
|
|
36
|
+
}, (error) => {
|
|
37
|
+
this.failed += 1;
|
|
38
|
+
this.active -= 1;
|
|
39
|
+
this.#drain();
|
|
40
|
+
this.#notify();
|
|
41
|
+
reject(error);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#notify() {
|
|
47
|
+
this.onChange?.(this.stats);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function mapConcurrent(items, limit, task) {
|
|
52
|
+
if (!Array.isArray(items)) throw new TypeError('Concurrent items must be an array');
|
|
53
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error('Concurrency must be a positive integer');
|
|
54
|
+
if (typeof task !== 'function') throw new TypeError('Concurrent task must be a function');
|
|
55
|
+
const results = new Array(items.length);
|
|
56
|
+
let nextIndex = 0;
|
|
57
|
+
let failure;
|
|
58
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
59
|
+
while (!failure && nextIndex < items.length) {
|
|
60
|
+
const index = nextIndex;
|
|
61
|
+
nextIndex += 1;
|
|
62
|
+
try { results[index] = await task(items[index], index); }
|
|
63
|
+
catch (error) { failure ??= error; }
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
await Promise.all(workers);
|
|
67
|
+
if (failure) throw failure;
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function limitRunner(runner, limiter) {
|
|
72
|
+
return {
|
|
73
|
+
run: (...args) => limiter.run(() => runner.run(...args)),
|
|
74
|
+
listSkills: (...args) => limiter.run(() => runner.listSkills(...args)),
|
|
75
|
+
version: (...args) => limiter.run(() => runner.version(...args)),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
4
|
+
|
|
5
|
+
import { approveAll, CopilotClient, RuntimeConnection } from '@github/copilot-sdk';
|
|
6
|
+
|
|
7
|
+
export class CopilotSdkRunError extends Error {}
|
|
8
|
+
|
|
9
|
+
export class CopilotSdkRunner {
|
|
10
|
+
constructor(config = {}, dependencies = {}) {
|
|
11
|
+
this.config = {
|
|
12
|
+
model: 'gpt-5.6-sol',
|
|
13
|
+
reasoningEffort: 'medium',
|
|
14
|
+
timeoutSeconds: 600,
|
|
15
|
+
maxTimeoutRetries: 1,
|
|
16
|
+
isolatedHome: undefined,
|
|
17
|
+
progress: undefined,
|
|
18
|
+
...config,
|
|
19
|
+
};
|
|
20
|
+
this.createClient = dependencies.createClient ?? ((options) => new CopilotClient(options));
|
|
21
|
+
this.clientPromise = undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async run(workspace, prompt) {
|
|
25
|
+
const startedAt = performance.now();
|
|
26
|
+
const client = await this.#client();
|
|
27
|
+
const skillDirectories = await projectSkillDirectories(workspace);
|
|
28
|
+
const invocation = explicitSkillInvocation(prompt, skillDirectories);
|
|
29
|
+
for (let attempt = 0; attempt <= this.config.maxTimeoutRetries; attempt += 1) {
|
|
30
|
+
const session = await client.createSession({
|
|
31
|
+
availableTools: ['builtin:*'],
|
|
32
|
+
excludedTools: ['shell', 'write', 'url'],
|
|
33
|
+
enableConfigDiscovery: false,
|
|
34
|
+
enableSessionStore: false,
|
|
35
|
+
enableSkills: skillDirectories.length > 0,
|
|
36
|
+
memory: { enabled: false },
|
|
37
|
+
model: this.config.model,
|
|
38
|
+
onPermissionRequest: approveAll,
|
|
39
|
+
reasoningEffort: this.config.reasoningEffort,
|
|
40
|
+
skillDirectories,
|
|
41
|
+
skipCustomInstructions: true,
|
|
42
|
+
workingDirectory: path.resolve(workspace),
|
|
43
|
+
...(invocation ? {
|
|
44
|
+
agent: 'skillfid-explicit',
|
|
45
|
+
customAgents: [{ name: 'skillfid-explicit', prompt: 'Answer the user directly and follow the preloaded skill instructions.', skills: [invocation.name], tools: null }],
|
|
46
|
+
} : {}),
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
const response = await session.sendAndWait({ prompt: invocation?.prompt ?? prompt }, this.config.timeoutSeconds * 1000);
|
|
50
|
+
const answer = response?.data.content?.trim();
|
|
51
|
+
if (!answer) throw new CopilotSdkRunError('Copilot SDK returned no assistant message');
|
|
52
|
+
return { answer, stderr: '', exitCode: 0, durationSeconds: (performance.now() - startedAt) / 1000 };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!isTimeout(error) || attempt === this.config.maxTimeoutRetries) throw new CopilotSdkRunError(error instanceof Error ? error.message : String(error), { cause: error });
|
|
55
|
+
this.config.progress?.({ type: 'retry', message: `Copilot timed out after ${this.config.timeoutSeconds} seconds; retrying (timeout retry ${attempt + 1}/${this.config.maxTimeoutRetries})`, details: { workspace: path.basename(workspace), attempt: attempt + 1, maxAttempts: this.config.maxTimeoutRetries } });
|
|
56
|
+
} finally {
|
|
57
|
+
await session.disconnect();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw new CopilotSdkRunError('Copilot SDK timeout attempts exhausted');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async listSkills(workspace) {
|
|
64
|
+
const client = await this.#client();
|
|
65
|
+
const skillDirectories = await projectSkillDirectories(workspace);
|
|
66
|
+
const session = await client.createSession({
|
|
67
|
+
availableTools: [],
|
|
68
|
+
enableConfigDiscovery: false,
|
|
69
|
+
enableSessionStore: false,
|
|
70
|
+
enableSkills: skillDirectories.length > 0,
|
|
71
|
+
memory: { enabled: false },
|
|
72
|
+
onPermissionRequest: approveAll,
|
|
73
|
+
skillDirectories,
|
|
74
|
+
skipCustomInstructions: true,
|
|
75
|
+
workingDirectory: path.resolve(workspace),
|
|
76
|
+
});
|
|
77
|
+
try {
|
|
78
|
+
const result = await session.rpc.skills.list();
|
|
79
|
+
return result.skills.map((skill) => ({ ...skill, source: skillDirectories.includes(path.dirname(skill.path ?? '')) ? 'project' : skill.source }));
|
|
80
|
+
} finally {
|
|
81
|
+
await session.disconnect();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async version() {
|
|
86
|
+
const status = await (await this.#client()).getStatus();
|
|
87
|
+
return `GitHub Copilot CLI ${status.version}.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async close() {
|
|
91
|
+
if (!this.clientPromise) return;
|
|
92
|
+
let client;
|
|
93
|
+
try { client = await this.clientPromise; }
|
|
94
|
+
catch { this.clientPromise = undefined; return; }
|
|
95
|
+
this.clientPromise = undefined;
|
|
96
|
+
await client.forceStop();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async #client() {
|
|
100
|
+
this.clientPromise ??= this.#startClient();
|
|
101
|
+
return this.clientPromise;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async #startClient() {
|
|
105
|
+
const options = {
|
|
106
|
+
baseDirectory: this.config.isolatedHome ? path.resolve(this.config.isolatedHome) : undefined,
|
|
107
|
+
logLevel: 'error',
|
|
108
|
+
};
|
|
109
|
+
if (this.config.executable) options.connection = RuntimeConnection.forStdio({ path: path.resolve(this.config.executable) });
|
|
110
|
+
const client = this.createClient(options);
|
|
111
|
+
await client.start();
|
|
112
|
+
return client;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function projectSkillDirectories(workspace) {
|
|
117
|
+
const root = path.resolve(workspace, '.github', 'skills');
|
|
118
|
+
let entries;
|
|
119
|
+
try { entries = await readdir(root, { withFileTypes: true }); }
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error.code === 'ENOENT') return [];
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => path.join(root, entry.name)).sort();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function explicitSkillInvocation(prompt, skillDirectories) {
|
|
128
|
+
const match = /^\/([^\s]+)\s*(?:\n+|$)/.exec(prompt);
|
|
129
|
+
if (!match) return undefined;
|
|
130
|
+
const directory = skillDirectories.find((item) => path.basename(item) === match[1]);
|
|
131
|
+
if (!directory) return undefined;
|
|
132
|
+
return { name: match[1], prompt: prompt.slice(match[0].length) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isTimeout(error) {
|
|
136
|
+
return error instanceof Error && /timeout|timed out/i.test(error.message);
|
|
137
|
+
}
|
package/src/corpus.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const HEADING_PATTERN = /^(#{1,6})\s+(.+?)\s*$/gm;
|
|
6
|
+
|
|
7
|
+
export async function loadCorpus(corpusPath) {
|
|
8
|
+
const root = path.resolve(corpusPath);
|
|
9
|
+
let entries;
|
|
10
|
+
try {
|
|
11
|
+
entries = await markdownFiles(root);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
14
|
+
throw new Error(`Corpus directory does not exist: ${corpusPath}`);
|
|
15
|
+
}
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (entries.length === 0) {
|
|
20
|
+
throw new Error(`Corpus contains no Markdown files: ${corpusPath}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return Promise.all(entries.map(async (filePath) => {
|
|
24
|
+
const content = normalizeText(await readFile(filePath, 'utf8'));
|
|
25
|
+
return {
|
|
26
|
+
documentId: path.relative(root, filePath).split(path.sep).join('/'),
|
|
27
|
+
path: filePath,
|
|
28
|
+
revision: sha256(content),
|
|
29
|
+
content,
|
|
30
|
+
};
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function splitDocument(document, maxChars = 12_000) {
|
|
35
|
+
if (maxChars < 500) {
|
|
36
|
+
throw new Error('maxChars must be at least 500');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const boundaries = [0];
|
|
40
|
+
for (const match of document.content.matchAll(HEADING_PATTERN)) {
|
|
41
|
+
if (match.index > 0) boundaries.push(match.index);
|
|
42
|
+
}
|
|
43
|
+
boundaries.push(document.content.length);
|
|
44
|
+
|
|
45
|
+
const sections = [];
|
|
46
|
+
for (let index = 0; index < boundaries.length - 1; index += 1) {
|
|
47
|
+
const start = boundaries[index];
|
|
48
|
+
const end = boundaries[index + 1];
|
|
49
|
+
const rawSection = document.content.slice(start, end);
|
|
50
|
+
if (!rawSection.trim()) continue;
|
|
51
|
+
HEADING_PATTERN.lastIndex = 0;
|
|
52
|
+
const headingMatch = HEADING_PATTERN.exec(rawSection);
|
|
53
|
+
const heading = headingMatch ? headingMatch[2].trim() : 'Preamble';
|
|
54
|
+
for (const [chunkStart, chunkEnd] of chunkBounds(rawSection, maxChars)) {
|
|
55
|
+
const absoluteStart = start + chunkStart;
|
|
56
|
+
const absoluteEnd = start + chunkEnd;
|
|
57
|
+
const content = document.content.slice(absoluteStart, absoluteEnd).trim();
|
|
58
|
+
if (!content) continue;
|
|
59
|
+
const sectionKey = `${document.documentId}:${absoluteStart}:${absoluteEnd}`;
|
|
60
|
+
sections.push({
|
|
61
|
+
sectionId: `sec_${sha256(sectionKey).slice(0, 16)}`,
|
|
62
|
+
documentId: document.documentId,
|
|
63
|
+
heading,
|
|
64
|
+
content,
|
|
65
|
+
start: absoluteStart,
|
|
66
|
+
end: absoluteEnd,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return sections;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function corpusRevision(documents) {
|
|
74
|
+
return sha256(documents.map((document) => `${document.documentId}:${document.revision}`).join('\n'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function markdownFiles(root) {
|
|
78
|
+
const files = [];
|
|
79
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
80
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
|
|
81
|
+
const entryPath = path.join(root, entry.name);
|
|
82
|
+
if (entry.isDirectory()) files.push(...await markdownFiles(entryPath));
|
|
83
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) files.push(entryPath);
|
|
84
|
+
}
|
|
85
|
+
return files;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function chunkBounds(content, maxChars) {
|
|
89
|
+
const bounds = [];
|
|
90
|
+
let start = 0;
|
|
91
|
+
while (start < content.length) {
|
|
92
|
+
const targetEnd = Math.min(start + maxChars, content.length);
|
|
93
|
+
let end = targetEnd;
|
|
94
|
+
if (targetEnd < content.length) {
|
|
95
|
+
const paragraphBreak = content.lastIndexOf('\n\n', targetEnd - 1);
|
|
96
|
+
const lineBreak = content.lastIndexOf('\n', targetEnd - 1);
|
|
97
|
+
const candidate = Math.max(paragraphBreak + 2, lineBreak + 1);
|
|
98
|
+
if (candidate > start) end = candidate;
|
|
99
|
+
}
|
|
100
|
+
bounds.push([start, end]);
|
|
101
|
+
start = end;
|
|
102
|
+
}
|
|
103
|
+
return bounds;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeText(content) {
|
|
107
|
+
return `${content.replaceAll('\r\n', '\n').replaceAll('\r', '\n').trim()}\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sha256(value) {
|
|
111
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
112
|
+
}
|