@cogineai/dearharness 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 +141 -0
- package/dist/cli.js +259 -0
- package/dist/config.js +21 -0
- package/dist/daemon/server.js +225 -0
- package/dist/extensions/builtin/policy-instructions.js +19 -0
- package/dist/extensions/loader.js +58 -0
- package/dist/extensions/types.js +1 -0
- package/dist/harness/install-applicator.js +126 -0
- package/dist/harness/install-apply.js +156 -0
- package/dist/harness/install-plan.js +154 -0
- package/dist/harness/install-runner.js +178 -0
- package/dist/harness/install-verification.js +117 -0
- package/dist/harness/lockfile.js +83 -0
- package/dist/harness/manifest.js +491 -0
- package/dist/harness/source.js +224 -0
- package/dist/harness/transaction.js +77 -0
- package/dist/harness/workspace.js +61 -0
- package/dist/index.js +9 -0
- package/dist/instructions/builder.js +33 -0
- package/dist/instructions/types.js +1 -0
- package/dist/model/config.js +100 -0
- package/dist/model/http.js +128 -0
- package/dist/model/index.js +22 -0
- package/dist/model/openrouter.js +9 -0
- package/dist/model/providers/anthropic.js +104 -0
- package/dist/model/providers/ollama-discovery.js +32 -0
- package/dist/model/providers/ollama.js +70 -0
- package/dist/model/providers/openai-compatible.js +118 -0
- package/dist/model/providers/openai.js +4 -0
- package/dist/model/providers/openrouter.js +79 -0
- package/dist/model/registry.js +108 -0
- package/dist/model/types.js +1 -0
- package/dist/policy/engine.js +30 -0
- package/dist/policy/types.js +1 -0
- package/dist/prompt/system.js +30 -0
- package/dist/protocol/actions.js +88 -0
- package/dist/runtime/assembly.js +54 -0
- package/dist/runtime/events.js +1 -0
- package/dist/runtime/hooks.js +13 -0
- package/dist/runtime/runner.js +193 -0
- package/dist/session/store.js +198 -0
- package/dist/session/types.js +1 -0
- package/dist/skills/loader.js +51 -0
- package/dist/skills/types.js +1 -0
- package/dist/tools/bash.js +71 -0
- package/dist/tools/edit.js +61 -0
- package/dist/tools/find.js +67 -0
- package/dist/tools/grep.js +88 -0
- package/dist/tools/ls.js +37 -0
- package/dist/tools/path.js +35 -0
- package/dist/tools/read.js +40 -0
- package/dist/tools/registry.js +18 -0
- package/dist/tools/types.js +1 -0
- package/dist/workspace/config.js +72 -0
- package/package.json +52 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { gunzipSync, unzipSync } from 'fflate';
|
|
6
|
+
import { validateHarnessSource } from './manifest.js';
|
|
7
|
+
const DEFAULT_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024;
|
|
8
|
+
const MANIFEST_FILE = 'dearharness.manifest.json';
|
|
9
|
+
const TAR_BLOCK_SIZE = 512;
|
|
10
|
+
function detectSourceKind(input) {
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(input);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new Error(`harness source must be an http(s) .zip or .tar.gz URL: ${input}`);
|
|
17
|
+
}
|
|
18
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
19
|
+
throw new Error(`harness source must be an http(s) .zip or .tar.gz URL: ${input}`);
|
|
20
|
+
}
|
|
21
|
+
const lowercasePath = url.pathname.toLowerCase();
|
|
22
|
+
if (lowercasePath.endsWith('.zip'))
|
|
23
|
+
return 'zip-url';
|
|
24
|
+
if (lowercasePath.endsWith('.tar.gz'))
|
|
25
|
+
return 'targz-url';
|
|
26
|
+
throw new Error(`harness source URL must point to a .zip or .tar.gz archive: ${input}`);
|
|
27
|
+
}
|
|
28
|
+
async function downloadArchive(input, fetchImpl, maxArchiveBytes) {
|
|
29
|
+
const response = await fetchImpl(input);
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
throw new Error(`failed to download harness source ${input}: HTTP ${response.status}`);
|
|
32
|
+
}
|
|
33
|
+
const contentLength = response.headers.get('content-length');
|
|
34
|
+
if (contentLength && Number(contentLength) > maxArchiveBytes) {
|
|
35
|
+
throw new Error(`harness source archive exceeds ${maxArchiveBytes} bytes`);
|
|
36
|
+
}
|
|
37
|
+
const archive = Buffer.from(await response.arrayBuffer());
|
|
38
|
+
if (archive.byteLength > maxArchiveBytes) {
|
|
39
|
+
throw new Error(`harness source archive exceeds ${maxArchiveBytes} bytes`);
|
|
40
|
+
}
|
|
41
|
+
return archive;
|
|
42
|
+
}
|
|
43
|
+
function safeArchiveEntryPath(root, entryName) {
|
|
44
|
+
if (!entryName || entryName.includes('\0')) {
|
|
45
|
+
throw new Error(`unsafe archive entry path: ${entryName}`);
|
|
46
|
+
}
|
|
47
|
+
const normalized = entryName.replace(/\\/g, '/');
|
|
48
|
+
if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) {
|
|
49
|
+
throw new Error(`unsafe archive entry path: ${entryName}`);
|
|
50
|
+
}
|
|
51
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
52
|
+
if (parts.length === 0 || parts.includes('..')) {
|
|
53
|
+
throw new Error(`unsafe archive entry path: ${entryName}`);
|
|
54
|
+
}
|
|
55
|
+
const target = path.resolve(root, ...parts);
|
|
56
|
+
const relative = path.relative(root, target);
|
|
57
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
58
|
+
throw new Error(`unsafe archive entry path: ${entryName}`);
|
|
59
|
+
}
|
|
60
|
+
return target;
|
|
61
|
+
}
|
|
62
|
+
async function extractZipToStaging(archive, stagingPath) {
|
|
63
|
+
const entries = unzipSync(archive);
|
|
64
|
+
for (const [entryName, data] of Object.entries(entries)) {
|
|
65
|
+
const target = safeArchiveEntryPath(stagingPath, entryName);
|
|
66
|
+
if (entryName.replace(/\\/g, '/').endsWith('/')) {
|
|
67
|
+
await fs.mkdir(target, { recursive: true });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
71
|
+
await fs.writeFile(target, data);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function isZeroBlock(view) {
|
|
75
|
+
for (let i = 0; i < view.byteLength; i++) {
|
|
76
|
+
if (view[i] !== 0)
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
function readCString(buffer, start, length) {
|
|
82
|
+
const slice = buffer.subarray(start, start + length);
|
|
83
|
+
let end = 0;
|
|
84
|
+
while (end < slice.byteLength && slice[end] !== 0)
|
|
85
|
+
end++;
|
|
86
|
+
return slice.subarray(0, end).toString('utf8');
|
|
87
|
+
}
|
|
88
|
+
function parseTarOctal(value, label) {
|
|
89
|
+
const trimmed = value.replace(/[\0 ]+$/g, '').trim();
|
|
90
|
+
if (trimmed.length === 0)
|
|
91
|
+
return 0;
|
|
92
|
+
if (!/^[0-7]+$/.test(trimmed)) {
|
|
93
|
+
throw new Error(`invalid octal value in tar entry ${label}: ${JSON.stringify(value)}`);
|
|
94
|
+
}
|
|
95
|
+
return parseInt(trimmed, 8);
|
|
96
|
+
}
|
|
97
|
+
function parseTarEntries(archive) {
|
|
98
|
+
const entries = [];
|
|
99
|
+
let offset = 0;
|
|
100
|
+
while (offset + TAR_BLOCK_SIZE <= archive.byteLength) {
|
|
101
|
+
const header = archive.subarray(offset, offset + TAR_BLOCK_SIZE);
|
|
102
|
+
if (isZeroBlock(header))
|
|
103
|
+
break;
|
|
104
|
+
const name = readCString(header, 0, 100);
|
|
105
|
+
const sizeRaw = readCString(header, 124, 12);
|
|
106
|
+
const typeflagByte = header[156] ?? 0;
|
|
107
|
+
const typeflag = typeflagByte === 0 ? '\0' : String.fromCharCode(typeflagByte);
|
|
108
|
+
const magic = readCString(header, 257, 6).trimEnd();
|
|
109
|
+
const prefix = readCString(header, 345, 155);
|
|
110
|
+
if (magic && magic !== 'ustar') {
|
|
111
|
+
throw new Error(`unsupported tar entry magic: ${JSON.stringify(magic)}`);
|
|
112
|
+
}
|
|
113
|
+
const size = parseTarOctal(sizeRaw, 'size');
|
|
114
|
+
const dataStart = offset + TAR_BLOCK_SIZE;
|
|
115
|
+
const dataEnd = dataStart + size;
|
|
116
|
+
const dataBlocks = Math.ceil(size / TAR_BLOCK_SIZE);
|
|
117
|
+
const nextOffset = dataStart + dataBlocks * TAR_BLOCK_SIZE;
|
|
118
|
+
if (dataEnd > archive.byteLength || nextOffset > archive.byteLength) {
|
|
119
|
+
throw new Error('tar entry data exceeds archive bounds');
|
|
120
|
+
}
|
|
121
|
+
offset = nextOffset;
|
|
122
|
+
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
123
|
+
if (typeflag === 'x' || typeflag === 'g') {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (typeflag === '5') {
|
|
127
|
+
entries.push({ kind: 'directory', name: fullName });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (typeflag === '0' || typeflag === '\0' || typeflag === '7') {
|
|
131
|
+
const data = Buffer.from(archive.subarray(dataStart, dataEnd));
|
|
132
|
+
entries.push({ kind: 'file', name: fullName, data });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (typeflag === '2') {
|
|
136
|
+
throw new Error(`unsupported tar entry type symlink: ${fullName}`);
|
|
137
|
+
}
|
|
138
|
+
if (typeflag === '1') {
|
|
139
|
+
throw new Error(`unsupported tar entry type hardlink: ${fullName}`);
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`unsupported tar entry type ${JSON.stringify(typeflag)}: ${fullName}`);
|
|
142
|
+
}
|
|
143
|
+
return entries;
|
|
144
|
+
}
|
|
145
|
+
async function extractTarGzToStaging(archive, stagingPath) {
|
|
146
|
+
let inflated;
|
|
147
|
+
try {
|
|
148
|
+
inflated = gunzipSync(archive);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
throw new Error(`failed to decompress harness source gzip stream: ${error instanceof Error ? error.message : String(error)}`);
|
|
152
|
+
}
|
|
153
|
+
const tarBuffer = Buffer.from(inflated);
|
|
154
|
+
const entries = parseTarEntries(tarBuffer);
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const target = safeArchiveEntryPath(stagingPath, entry.name);
|
|
157
|
+
if (entry.kind === 'directory') {
|
|
158
|
+
await fs.mkdir(target, { recursive: true });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
162
|
+
await fs.writeFile(target, entry.data);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function pathExists(target) {
|
|
166
|
+
try {
|
|
167
|
+
await fs.access(target);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function findHarnessRoot(stagingPath) {
|
|
175
|
+
if (await pathExists(path.join(stagingPath, MANIFEST_FILE))) {
|
|
176
|
+
return stagingPath;
|
|
177
|
+
}
|
|
178
|
+
const entries = await fs.readdir(stagingPath, { withFileTypes: true });
|
|
179
|
+
const manifestRoots = [];
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
if (!entry.isDirectory())
|
|
182
|
+
continue;
|
|
183
|
+
const candidate = path.join(stagingPath, entry.name);
|
|
184
|
+
if (await pathExists(path.join(candidate, MANIFEST_FILE))) {
|
|
185
|
+
manifestRoots.push(candidate);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return manifestRoots.length === 1 ? manifestRoots[0] : stagingPath;
|
|
189
|
+
}
|
|
190
|
+
export async function materializeHarnessSource(input, options = {}) {
|
|
191
|
+
const kind = detectSourceKind(input);
|
|
192
|
+
const tempRoot = options.tempRoot ?? os.tmpdir();
|
|
193
|
+
const stagingPath = await fs.mkdtemp(path.join(tempRoot, 'dearharness-source-'));
|
|
194
|
+
try {
|
|
195
|
+
const archive = await downloadArchive(input, options.fetch ?? fetch, options.maxArchiveBytes ?? DEFAULT_MAX_ARCHIVE_BYTES);
|
|
196
|
+
const sha256 = createHash('sha256').update(archive).digest('hex');
|
|
197
|
+
if (options.expectedSha256 && options.expectedSha256.toLowerCase() !== sha256) {
|
|
198
|
+
throw new Error(`harness source sha256 mismatch: expected ${options.expectedSha256}, got ${sha256}`);
|
|
199
|
+
}
|
|
200
|
+
if (kind === 'zip-url') {
|
|
201
|
+
await extractZipToStaging(archive, stagingPath);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
await extractTarGzToStaging(archive, stagingPath);
|
|
205
|
+
}
|
|
206
|
+
const rootPath = await findHarnessRoot(stagingPath);
|
|
207
|
+
const validation = await validateHarnessSource(rootPath);
|
|
208
|
+
return {
|
|
209
|
+
input,
|
|
210
|
+
kind,
|
|
211
|
+
stagingPath,
|
|
212
|
+
rootPath,
|
|
213
|
+
sha256,
|
|
214
|
+
validation,
|
|
215
|
+
cleanup: async () => {
|
|
216
|
+
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
function toPosixPath(value) {
|
|
6
|
+
return value.split(path.sep).join('/');
|
|
7
|
+
}
|
|
8
|
+
async function assertDirectory(target, label) {
|
|
9
|
+
const stat = await fs.stat(target);
|
|
10
|
+
if (!stat.isDirectory()) {
|
|
11
|
+
throw new Error(`${label} must be a directory: ${target}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function createWorkspaceTransaction(targetWorkspaceInput, options = {}) {
|
|
15
|
+
const targetWorkspace = path.resolve(targetWorkspaceInput);
|
|
16
|
+
await assertDirectory(targetWorkspace, 'targetWorkspace');
|
|
17
|
+
const tempRoot = options.tempRoot ?? os.tmpdir();
|
|
18
|
+
const stagingPath = await fs.mkdtemp(path.join(tempRoot, 'dearharness-transaction-'));
|
|
19
|
+
const candidateWorkspace = path.join(stagingPath, 'workspace');
|
|
20
|
+
try {
|
|
21
|
+
await fs.cp(targetWorkspace, candidateWorkspace, { recursive: true });
|
|
22
|
+
return {
|
|
23
|
+
targetWorkspace,
|
|
24
|
+
stagingPath,
|
|
25
|
+
candidateWorkspace,
|
|
26
|
+
cleanup: async () => {
|
|
27
|
+
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function hashFile(target) {
|
|
37
|
+
return createHash('sha256').update(await fs.readFile(target)).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
async function collectFileHashes(root) {
|
|
40
|
+
const files = new Map();
|
|
41
|
+
async function visit(directory) {
|
|
42
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const target = path.join(directory, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
await visit(target);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!entry.isFile()) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
files.set(toPosixPath(path.relative(root, target)), await hashFile(target));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
await visit(root);
|
|
56
|
+
return files;
|
|
57
|
+
}
|
|
58
|
+
export async function diffWorkspaceTransaction(transaction) {
|
|
59
|
+
const before = await collectFileHashes(transaction.targetWorkspace);
|
|
60
|
+
const after = await collectFileHashes(transaction.candidateWorkspace);
|
|
61
|
+
const paths = [...new Set([...before.keys(), ...after.keys()])].sort((a, b) => a.localeCompare(b));
|
|
62
|
+
const diff = [];
|
|
63
|
+
for (const relativePath of paths) {
|
|
64
|
+
const beforeHash = before.get(relativePath);
|
|
65
|
+
const afterHash = after.get(relativePath);
|
|
66
|
+
if (beforeHash === undefined && afterHash !== undefined) {
|
|
67
|
+
diff.push({ path: relativePath, kind: 'add' });
|
|
68
|
+
}
|
|
69
|
+
else if (beforeHash !== undefined && afterHash === undefined) {
|
|
70
|
+
diff.push({ path: relativePath, kind: 'delete' });
|
|
71
|
+
}
|
|
72
|
+
else if (beforeHash !== afterHash) {
|
|
73
|
+
diff.push({ path: relativePath, kind: 'modify' });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return diff;
|
|
77
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readInstallLockfile } from './lockfile.js';
|
|
4
|
+
const BOOTSTRAP_FILES = ['AGENTS.md', 'SOUL.md', 'USER.md', 'IDENTITY.md', 'TOOLS.md', 'HEARTBEAT.md', 'BOOTSTRAP.md'];
|
|
5
|
+
async function statFile(target) {
|
|
6
|
+
try {
|
|
7
|
+
return await fs.stat(target);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function readBootstrapFiles(workspace) {
|
|
14
|
+
const files = [];
|
|
15
|
+
for (const name of BOOTSTRAP_FILES) {
|
|
16
|
+
const stat = await statFile(path.join(workspace, name));
|
|
17
|
+
files.push({ name, present: Boolean(stat?.isFile()), sizeBytes: stat?.isFile() ? stat.size : null });
|
|
18
|
+
}
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
async function readSkills(workspace) {
|
|
22
|
+
const skillsDir = path.join(workspace, 'skills');
|
|
23
|
+
let entries;
|
|
24
|
+
try {
|
|
25
|
+
entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
const skills = [];
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (!entry.isDirectory())
|
|
33
|
+
continue;
|
|
34
|
+
const skillEntry = await statFile(path.join(skillsDir, entry.name, 'SKILL.md'));
|
|
35
|
+
skills.push({ slug: entry.name, hasEntry: Boolean(skillEntry?.isFile()) });
|
|
36
|
+
}
|
|
37
|
+
return skills.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
38
|
+
}
|
|
39
|
+
export async function inspectOpenClawWorkspace(workspaceInput) {
|
|
40
|
+
const workspace = path.resolve(workspaceInput);
|
|
41
|
+
const lockfile = await readInstallLockfile(workspace);
|
|
42
|
+
const stat = await statFile(workspace);
|
|
43
|
+
if (!stat?.isDirectory()) {
|
|
44
|
+
return {
|
|
45
|
+
workspace,
|
|
46
|
+
exists: false,
|
|
47
|
+
bootstrapFiles: [],
|
|
48
|
+
skills: [],
|
|
49
|
+
lockfile,
|
|
50
|
+
errors: [`workspace does not exist: ${workspace}`],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
workspace,
|
|
55
|
+
exists: true,
|
|
56
|
+
bootstrapFiles: await readBootstrapFiles(workspace),
|
|
57
|
+
skills: await readSkills(workspace),
|
|
58
|
+
lockfile,
|
|
59
|
+
errors: [...lockfile.errors],
|
|
60
|
+
};
|
|
61
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { resolveWorkspacePath } from '../tools/path.js';
|
|
3
|
+
export async function loadWorkspaceInstructionFiles(cwd, files) {
|
|
4
|
+
const loaded = [];
|
|
5
|
+
for (const file of files) {
|
|
6
|
+
const { targetRealPath } = await resolveWorkspacePath(cwd, file);
|
|
7
|
+
loaded.push((await fs.readFile(targetRealPath, 'utf8')).trim());
|
|
8
|
+
}
|
|
9
|
+
return loaded.filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
export async function buildInstructionMessages(options) {
|
|
12
|
+
const messages = [
|
|
13
|
+
{ role: 'system', layer: 'core', source: 'base', content: options.basePrompt }
|
|
14
|
+
];
|
|
15
|
+
for (const instruction of options.workspaceInstructions) {
|
|
16
|
+
messages.push({
|
|
17
|
+
role: 'system',
|
|
18
|
+
layer: 'workspace',
|
|
19
|
+
source: 'workspace',
|
|
20
|
+
content: instruction
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
for (const skill of options.skills) {
|
|
24
|
+
messages.push({
|
|
25
|
+
role: 'system',
|
|
26
|
+
layer: 'skill',
|
|
27
|
+
source: `skill:${skill.name}`,
|
|
28
|
+
content: skill.prompt
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
messages.push(...options.extensionMessages);
|
|
32
|
+
return messages;
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { OLLAMA_DEFAULT_MODEL_HINT } from '../config.js';
|
|
2
|
+
import { discoverOllamaModels, selectDefaultOllamaModel } from './providers/ollama-discovery.js';
|
|
3
|
+
import { DEFAULT_MODEL_CONFIG, getModelProvider, isProviderName } from './registry.js';
|
|
4
|
+
export function isStreamingMode(value) {
|
|
5
|
+
return value === 'auto' || value === 'on' || value === 'off';
|
|
6
|
+
}
|
|
7
|
+
function firstDefined(...values) {
|
|
8
|
+
for (const value of values) {
|
|
9
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
function getProviderApiKey(provider) {
|
|
16
|
+
if (provider === 'openrouter')
|
|
17
|
+
return process.env.OPENROUTER_API_KEY;
|
|
18
|
+
if (provider === 'anthropic')
|
|
19
|
+
return process.env.ANTHROPIC_API_KEY;
|
|
20
|
+
if (provider === 'openai')
|
|
21
|
+
return process.env.OPENAI_API_KEY;
|
|
22
|
+
if (provider === 'openai-compatible') {
|
|
23
|
+
return firstDefined(process.env.CLIQ_MODEL_API_KEY, process.env.OPENAI_COMPATIBLE_API_KEY);
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
function requireApiKey(provider, apiKey) {
|
|
28
|
+
if (provider === 'ollama' || provider === 'openai-compatible') {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!apiKey) {
|
|
32
|
+
throw new Error(`${provider} API key is required`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function buildNoLocalModelConfiguredError(baseUrl, cause) {
|
|
36
|
+
const causeMessage = cause instanceof Error ? cause.message : cause ? String(cause) : '';
|
|
37
|
+
return new Error([
|
|
38
|
+
'No model provider or local Ollama model configured.',
|
|
39
|
+
'',
|
|
40
|
+
`Cliq defaults to local Ollama at ${baseUrl} when no model provider is configured, but no local model could be selected.`,
|
|
41
|
+
'',
|
|
42
|
+
'Options:',
|
|
43
|
+
` - Install a local model: ollama pull ${OLLAMA_DEFAULT_MODEL_HINT}`,
|
|
44
|
+
' - Select an existing local model: cliq --provider ollama --model <model> "task"',
|
|
45
|
+
' - Configure a remote provider with --provider, --model, and the required API key',
|
|
46
|
+
...(causeMessage ? ['', `Ollama discovery error: ${causeMessage}`] : [])
|
|
47
|
+
].join('\n'));
|
|
48
|
+
}
|
|
49
|
+
async function discoverDefaultOllamaModel(baseUrl) {
|
|
50
|
+
let models;
|
|
51
|
+
try {
|
|
52
|
+
models = await discoverOllamaModels(baseUrl);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw buildNoLocalModelConfiguredError(baseUrl, error);
|
|
56
|
+
}
|
|
57
|
+
const selected = selectDefaultOllamaModel(models);
|
|
58
|
+
if (!selected) {
|
|
59
|
+
throw buildNoLocalModelConfiguredError(baseUrl);
|
|
60
|
+
}
|
|
61
|
+
return selected;
|
|
62
|
+
}
|
|
63
|
+
export async function resolveModelConfig({ workspace, cli }) {
|
|
64
|
+
const rawProvider = firstDefined(cli.provider, workspace.model?.provider, process.env.CLIQ_MODEL_PROVIDER);
|
|
65
|
+
let provider;
|
|
66
|
+
if (rawProvider) {
|
|
67
|
+
if (!isProviderName(rawProvider)) {
|
|
68
|
+
throw new Error(`Unknown model provider: ${rawProvider}`);
|
|
69
|
+
}
|
|
70
|
+
provider = rawProvider;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
provider = 'ollama';
|
|
74
|
+
}
|
|
75
|
+
const providerDef = getModelProvider(provider);
|
|
76
|
+
const rawStreaming = firstDefined(cli.streaming, workspace.model?.streaming, process.env.CLIQ_MODEL_STREAMING, DEFAULT_MODEL_CONFIG.streaming);
|
|
77
|
+
if (!rawStreaming || !isStreamingMode(rawStreaming)) {
|
|
78
|
+
throw new Error(`Invalid streaming mode: ${rawStreaming ?? ''}`);
|
|
79
|
+
}
|
|
80
|
+
let model = firstDefined(cli.model, workspace.model?.model, process.env.CLIQ_MODEL, providerDef.getDefaultModel());
|
|
81
|
+
const baseUrl = firstDefined(cli.baseUrl, workspace.model?.baseUrl, process.env.CLIQ_MODEL_BASE_URL, providerDef.defaultBaseUrl);
|
|
82
|
+
if (!baseUrl) {
|
|
83
|
+
throw new Error(`baseUrl is required for provider ${provider}`);
|
|
84
|
+
}
|
|
85
|
+
if (!model && provider === 'ollama') {
|
|
86
|
+
model = await discoverDefaultOllamaModel(baseUrl);
|
|
87
|
+
}
|
|
88
|
+
if (!model) {
|
|
89
|
+
throw new Error(`model is required for provider ${provider}`);
|
|
90
|
+
}
|
|
91
|
+
const apiKey = getProviderApiKey(provider);
|
|
92
|
+
requireApiKey(provider, apiKey);
|
|
93
|
+
return {
|
|
94
|
+
provider,
|
|
95
|
+
model,
|
|
96
|
+
baseUrl,
|
|
97
|
+
...(apiKey ? { apiKey } : {}),
|
|
98
|
+
streaming: rawStreaming
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { MODEL_TIMEOUT_MS } from '../config.js';
|
|
2
|
+
export function joinUrl(baseUrl, pathname) {
|
|
3
|
+
return `${baseUrl.replace(/\/+$/, '')}/${pathname.replace(/^\/+/, '')}`;
|
|
4
|
+
}
|
|
5
|
+
export async function fetchWithTimeout(url, init, timeoutMs = MODEL_TIMEOUT_MS) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
8
|
+
try {
|
|
9
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
13
|
+
throw new Error(`Model request timed out after ${timeoutMs}ms`);
|
|
14
|
+
}
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
clearTimeout(timeout);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function readJsonResponse(response, providerName) {
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error(`${providerName} error ${response.status}: ${await response.text()}`);
|
|
24
|
+
}
|
|
25
|
+
return (await response.json());
|
|
26
|
+
}
|
|
27
|
+
export async function readTextStream(response, onChunk) {
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`Model stream error ${response.status}: ${await response.text()}`);
|
|
30
|
+
}
|
|
31
|
+
if (!response.body) {
|
|
32
|
+
throw new Error('Model stream response is missing a body');
|
|
33
|
+
}
|
|
34
|
+
const reader = response.body.getReader();
|
|
35
|
+
const decoder = new TextDecoder();
|
|
36
|
+
let output = '';
|
|
37
|
+
for (;;) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done)
|
|
40
|
+
break;
|
|
41
|
+
const text = decoder.decode(value, { stream: true });
|
|
42
|
+
output += text;
|
|
43
|
+
await onChunk(text);
|
|
44
|
+
}
|
|
45
|
+
const finalText = decoder.decode();
|
|
46
|
+
output += finalText;
|
|
47
|
+
if (finalText) {
|
|
48
|
+
await onChunk(finalText);
|
|
49
|
+
}
|
|
50
|
+
return output;
|
|
51
|
+
}
|
|
52
|
+
function warnMalformedStreamPayload(format, payload, error) {
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
console.warn(`Malformed model ${format} payload skipped: ${message}; payload=${payload}`);
|
|
55
|
+
}
|
|
56
|
+
export async function readSseDeltas(response, extractDelta, onDelta) {
|
|
57
|
+
let buffer = '';
|
|
58
|
+
let content = '';
|
|
59
|
+
async function processFrame(frame) {
|
|
60
|
+
for (const line of frame.split('\n')) {
|
|
61
|
+
const normalized = line.trimEnd();
|
|
62
|
+
if (!normalized.startsWith('data:'))
|
|
63
|
+
continue;
|
|
64
|
+
const payload = normalized.slice('data:'.length).trim();
|
|
65
|
+
if (!payload || payload === '[DONE]')
|
|
66
|
+
continue;
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(payload);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
warnMalformedStreamPayload('SSE', payload, error);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const delta = extractDelta(parsed);
|
|
76
|
+
if (delta) {
|
|
77
|
+
content += delta;
|
|
78
|
+
await onDelta(delta);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await readTextStream(response, async (chunk) => {
|
|
83
|
+
buffer += chunk;
|
|
84
|
+
const frames = buffer.split(/\r?\n\r?\n/);
|
|
85
|
+
buffer = frames.pop() ?? '';
|
|
86
|
+
for (const frame of frames) {
|
|
87
|
+
await processFrame(frame);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
if (buffer.trim()) {
|
|
91
|
+
await processFrame(buffer);
|
|
92
|
+
}
|
|
93
|
+
return content;
|
|
94
|
+
}
|
|
95
|
+
export async function readNdjsonDeltas(response, extractDelta, onDelta) {
|
|
96
|
+
let buffer = '';
|
|
97
|
+
let content = '';
|
|
98
|
+
async function processLine(line) {
|
|
99
|
+
const payload = line.trim();
|
|
100
|
+
if (!payload)
|
|
101
|
+
return;
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(payload);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
warnMalformedStreamPayload('NDJSON', payload, error);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const delta = extractDelta(parsed);
|
|
111
|
+
if (delta) {
|
|
112
|
+
content += delta;
|
|
113
|
+
await onDelta(delta);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
await readTextStream(response, async (chunk) => {
|
|
117
|
+
buffer += chunk;
|
|
118
|
+
const lines = buffer.split('\n');
|
|
119
|
+
buffer = lines.pop() ?? '';
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
await processLine(line);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
if (buffer.trim()) {
|
|
125
|
+
await processLine(buffer);
|
|
126
|
+
}
|
|
127
|
+
return content;
|
|
128
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getModelProvider, registerModelClientFactory } from './registry.js';
|
|
2
|
+
import { createAnthropicClient } from './providers/anthropic.js';
|
|
3
|
+
import { createOllamaClient } from './providers/ollama.js';
|
|
4
|
+
import { createOpenAIClient } from './providers/openai.js';
|
|
5
|
+
import { createOpenAICompatibleClient } from './providers/openai-compatible.js';
|
|
6
|
+
import { createOpenRouterClient } from './providers/openrouter.js';
|
|
7
|
+
let registered = false;
|
|
8
|
+
export function registerBuiltInModelProviders() {
|
|
9
|
+
if (registered) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
registerModelClientFactory('openrouter', createOpenRouterClient);
|
|
13
|
+
registerModelClientFactory('anthropic', createAnthropicClient);
|
|
14
|
+
registerModelClientFactory('openai', createOpenAIClient);
|
|
15
|
+
registerModelClientFactory('openai-compatible', createOpenAICompatibleClient);
|
|
16
|
+
registerModelClientFactory('ollama', createOllamaClient);
|
|
17
|
+
registered = true;
|
|
18
|
+
}
|
|
19
|
+
export function createModelClient(config) {
|
|
20
|
+
registerBuiltInModelProviders();
|
|
21
|
+
return getModelProvider(config.provider).createClient(config);
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { DEFAULT_MODEL_CONFIG } from './registry.js';
|
|
2
|
+
import { createOpenRouterClient as createOpenRouterProviderClient } from './providers/openrouter.js';
|
|
3
|
+
export function createOpenRouterClient(config) {
|
|
4
|
+
const resolved = config ?? {
|
|
5
|
+
...DEFAULT_MODEL_CONFIG,
|
|
6
|
+
apiKey: process.env.OPENROUTER_API_KEY
|
|
7
|
+
};
|
|
8
|
+
return createOpenRouterProviderClient(resolved);
|
|
9
|
+
}
|