klyro 0.1.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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/agent/anthropic-adapter.d.ts +75 -0
- package/dist/agent/anthropic-adapter.js +297 -0
- package/dist/agent/message.d.ts +40 -0
- package/dist/agent/message.js +21 -0
- package/dist/agent/observation.d.ts +47 -0
- package/dist/agent/observation.js +53 -0
- package/dist/agent/provider-adapter.d.ts +101 -0
- package/dist/agent/provider-adapter.js +254 -0
- package/dist/agent/registry.d.ts +42 -0
- package/dist/agent/registry.js +86 -0
- package/dist/agent/retry.d.ts +34 -0
- package/dist/agent/retry.js +91 -0
- package/dist/agent/runtime.d.ts +130 -0
- package/dist/agent/runtime.js +221 -0
- package/dist/agent/worker-spawner.d.ts +41 -0
- package/dist/agent/worker-spawner.js +76 -0
- package/dist/chat.d.ts +39 -0
- package/dist/chat.js +235 -0
- package/dist/chat.js.map +1 -0
- package/dist/cli/eval.d.ts +75 -0
- package/dist/cli/eval.js +190 -0
- package/dist/cli/repl.d.ts +15 -0
- package/dist/cli/repl.js +194 -0
- package/dist/cli/run.d.ts +67 -0
- package/dist/cli/run.js +198 -0
- package/dist/cli/slash/parser.d.ts +39 -0
- package/dist/cli/slash/parser.js +47 -0
- package/dist/context/level6.d.ts +45 -0
- package/dist/context/level6.js +231 -0
- package/dist/context/level7.d.ts +84 -0
- package/dist/context/level7.js +156 -0
- package/dist/context/project-map.d.ts +48 -0
- package/dist/context/project-map.js +438 -0
- package/dist/context/repo-map.d.ts +30 -0
- package/dist/context/repo-map.js +168 -0
- package/dist/context/selector.d.ts +22 -0
- package/dist/context/selector.js +37 -0
- package/dist/context/snippets.d.ts +17 -0
- package/dist/context/snippets.js +15 -0
- package/dist/context/tokenizer.d.ts +49 -0
- package/dist/context/tokenizer.js +97 -0
- package/dist/eval/harness.d.ts +48 -0
- package/dist/eval/harness.js +149 -0
- package/dist/eval/tasks.d.ts +6 -0
- package/dist/eval/tasks.js +98 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -0
- package/dist/persistence/audit.d.ts +75 -0
- package/dist/persistence/audit.js +15 -0
- package/dist/persistence/store.d.ts +67 -0
- package/dist/persistence/store.js +106 -0
- package/dist/policy/approval.d.ts +35 -0
- package/dist/policy/approval.js +54 -0
- package/dist/policy/engine.d.ts +76 -0
- package/dist/policy/engine.js +163 -0
- package/dist/policy/path-guard.d.ts +30 -0
- package/dist/policy/path-guard.js +100 -0
- package/dist/policy/secret-redactor.d.ts +19 -0
- package/dist/policy/secret-redactor.js +52 -0
- package/dist/providers.d.ts +23 -0
- package/dist/providers.js +83 -0
- package/dist/repl.d.ts +9 -0
- package/dist/repl.js +170 -0
- package/dist/repl.js.map +1 -0
- package/dist/tools/fs/edit-file.d.ts +48 -0
- package/dist/tools/fs/edit-file.js +84 -0
- package/dist/tools/fs/list-dir.d.ts +40 -0
- package/dist/tools/fs/list-dir.js +72 -0
- package/dist/tools/fs/read-file.d.ts +66 -0
- package/dist/tools/fs/read-file.js +75 -0
- package/dist/tools/fs/write-file.d.ts +24 -0
- package/dist/tools/fs/write-file.js +48 -0
- package/dist/tools/git/git-diff.d.ts +35 -0
- package/dist/tools/git/git-diff.js +68 -0
- package/dist/tools/git/git-status.d.ts +29 -0
- package/dist/tools/git/git-status.js +57 -0
- package/dist/tools/normalize.d.ts +31 -0
- package/dist/tools/normalize.js +88 -0
- package/dist/tools/registry.d.ts +23 -0
- package/dist/tools/registry.js +87 -0
- package/dist/tools/schema.d.ts +29 -0
- package/dist/tools/schema.js +183 -0
- package/dist/tools/search/dependencies.d.ts +44 -0
- package/dist/tools/search/dependencies.js +188 -0
- package/dist/tools/search/glob.d.ts +26 -0
- package/dist/tools/search/glob.js +88 -0
- package/dist/tools/search/grep.d.ts +52 -0
- package/dist/tools/search/grep.js +125 -0
- package/dist/tools/search/recent-files.d.ts +33 -0
- package/dist/tools/search/recent-files.js +100 -0
- package/dist/tools/search/search-files.d.ts +39 -0
- package/dist/tools/search/search-files.js +110 -0
- package/dist/tools/shell/shell-exec.d.ts +60 -0
- package/dist/tools/shell/shell-exec.js +133 -0
- package/dist/tools/types.d.ts +54 -0
- package/dist/tools/types.js +15 -0
- package/dist/tools/verify/run-verify.d.ts +60 -0
- package/dist/tools/verify/run-verify.js +115 -0
- package/dist/tui/app.d.ts +27 -0
- package/dist/tui/app.js +79 -0
- package/dist/tui/app.test.d.ts +1 -0
- package/dist/tui/app.test.js +77 -0
- package/dist/tui/status.d.ts +21 -0
- package/dist/tui/status.js +13 -0
- package/dist/tui/status.test.d.ts +1 -0
- package/dist/tui/status.test.js +39 -0
- package/dist/tui/transcript.d.ts +40 -0
- package/dist/tui/transcript.js +28 -0
- package/dist/tui/transcript.test.d.ts +1 -0
- package/dist/tui/transcript.test.js +71 -0
- package/dist/verification/detect.d.ts +29 -0
- package/dist/verification/detect.js +85 -0
- package/dist/verification/engine.d.ts +25 -0
- package/dist/verification/engine.js +57 -0
- package/package.json +58 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path safety: ensure every file path the agent touches stays inside `cwd`.
|
|
3
|
+
*
|
|
4
|
+
* Refuses:
|
|
5
|
+
* - absolute paths outside cwd
|
|
6
|
+
* - parent-directory traversal (`..`)
|
|
7
|
+
* - symlinks that resolve outside cwd
|
|
8
|
+
*
|
|
9
|
+
* Uses `path.resolve` then `path.relative` to check. On Windows, the
|
|
10
|
+
* comparison is case-insensitive.
|
|
11
|
+
*/
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import * as fs from 'node:fs/promises';
|
|
14
|
+
import { TOOL_ERROR_CODES } from '../tools/normalize.js';
|
|
15
|
+
export class PathGuardError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.name = 'PathGuardError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function isWindows() {
|
|
24
|
+
return process.platform === 'win32';
|
|
25
|
+
}
|
|
26
|
+
function normalizeForCompare(p) {
|
|
27
|
+
// Realpath resolves symlinks and gives a canonical absolute path.
|
|
28
|
+
// We don't realpath here (caller does that explicitly when needed).
|
|
29
|
+
// For the within-cwd check we normalize case on Windows.
|
|
30
|
+
return isWindows() ? p.toLowerCase() : p;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a (possibly relative) path against cwd and assert it stays inside.
|
|
34
|
+
* Does NOT follow symlinks — call `resolveAndFollowSymlinks` for that.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveWithinCwd(cwd, requested) {
|
|
37
|
+
if (!requested || requested.length === 0) {
|
|
38
|
+
throw new PathGuardError(TOOL_ERROR_CODES.INVALID_INPUT, 'Path is empty');
|
|
39
|
+
}
|
|
40
|
+
const absCwd = path.resolve(cwd);
|
|
41
|
+
const resolved = path.isAbsolute(requested) ? path.resolve(requested) : path.resolve(absCwd, requested);
|
|
42
|
+
// Use relative to test containment. path.relative throws on different
|
|
43
|
+
// drives on Windows; normalize to handle that.
|
|
44
|
+
const rel = path.relative(absCwd, resolved);
|
|
45
|
+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
|
|
46
|
+
return { resolved };
|
|
47
|
+
}
|
|
48
|
+
// Different drive on Windows: relative starts with a drive letter
|
|
49
|
+
if (isWindows() && /^[A-Za-z]:[\\/]/.test(rel)) {
|
|
50
|
+
throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Path escapes cwd (different drive): ${requested}`);
|
|
51
|
+
}
|
|
52
|
+
throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Path escapes cwd: ${requested}`);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve, follow symlinks, and assert the target stays inside cwd.
|
|
56
|
+
* Use this for read_file / write_file to defeat symlink-based escapes.
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveAndFollowSymlinks(cwd, requested) {
|
|
59
|
+
const { resolved } = resolveWithinCwd(cwd, requested);
|
|
60
|
+
let real;
|
|
61
|
+
let realParent;
|
|
62
|
+
try {
|
|
63
|
+
real = await fs.realpath(resolved);
|
|
64
|
+
realParent = path.dirname(real);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// File doesn't exist yet (e.g. write_file). realpath would fail; fall
|
|
68
|
+
// back to realpath-ing the parent.
|
|
69
|
+
const parent = path.dirname(resolved);
|
|
70
|
+
try {
|
|
71
|
+
realParent = await fs.realpath(parent);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Parent doesn't exist either. Don't trust the unresolved parent —
|
|
75
|
+
// re-validate it against cwd and bail if it's not inside.
|
|
76
|
+
const { resolved: parentResolved } = resolveWithinCwd(cwd, parent);
|
|
77
|
+
realParent = parentResolved;
|
|
78
|
+
}
|
|
79
|
+
real = path.join(realParent, path.basename(resolved));
|
|
80
|
+
}
|
|
81
|
+
const absCwd = await fs.realpath(cwd).catch(() => path.resolve(cwd));
|
|
82
|
+
const cmpReal = normalizeForCompare(real);
|
|
83
|
+
const cmpCwd = normalizeForCompare(absCwd);
|
|
84
|
+
if (cmpReal !== cmpCwd && !cmpReal.startsWith(cmpCwd + path.sep)) {
|
|
85
|
+
throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Symlink target escapes cwd: ${requested} -> ${real}`);
|
|
86
|
+
}
|
|
87
|
+
// Defense-in-depth: ensure the parent directory of the resolved target
|
|
88
|
+
// is also inside cwd. This catches the case where the requested path
|
|
89
|
+
// walks through a symlinked parent (e.g. `<cwd>/evil-link/../escape`)
|
|
90
|
+
// and `real` happens to land back inside cwd but the parent was outside.
|
|
91
|
+
// Skip when the target itself is cwd (realParent would be the parent of
|
|
92
|
+
// cwd, which is necessarily outside).
|
|
93
|
+
if (real !== absCwd) {
|
|
94
|
+
const cmpRealParent = normalizeForCompare(realParent);
|
|
95
|
+
if (cmpRealParent !== cmpCwd && !cmpRealParent.startsWith(cmpCwd + path.sep)) {
|
|
96
|
+
throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Path escapes cwd via parent symlink: ${requested} -> ${realParent}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { resolved: real };
|
|
100
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction — strip common credential shapes from tool outputs
|
|
3
|
+
* before they enter the transcript.
|
|
4
|
+
*
|
|
5
|
+
* Pattern coverage: AWS access keys, PEM blocks, GitHub tokens, Slack tokens,
|
|
6
|
+
* generic bearer/JWT, and high-entropy hex strings (best-effort).
|
|
7
|
+
*
|
|
8
|
+
* Streaming: we expose `createRedactor()` returning a Transform so large
|
|
9
|
+
* outputs don't have to buffer in memory.
|
|
10
|
+
*/
|
|
11
|
+
import { Transform } from 'node:stream';
|
|
12
|
+
/** Redact a single string (or Buffer). */
|
|
13
|
+
export declare function redact(input: string | Buffer): string;
|
|
14
|
+
/**
|
|
15
|
+
* Streaming transform that redacts as bytes flow through. Useful for
|
|
16
|
+
* piping shell stdout/stderr into the transcript without buffering the
|
|
17
|
+
* full output in memory.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createRedactor(): Transform;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction — strip common credential shapes from tool outputs
|
|
3
|
+
* before they enter the transcript.
|
|
4
|
+
*
|
|
5
|
+
* Pattern coverage: AWS access keys, PEM blocks, GitHub tokens, Slack tokens,
|
|
6
|
+
* generic bearer/JWT, and high-entropy hex strings (best-effort).
|
|
7
|
+
*
|
|
8
|
+
* Streaming: we expose `createRedactor()` returning a Transform so large
|
|
9
|
+
* outputs don't have to buffer in memory.
|
|
10
|
+
*/
|
|
11
|
+
import { Transform } from 'node:stream';
|
|
12
|
+
const PATTERNS = [
|
|
13
|
+
{ name: 'aws-key', re: /AKIA[0-9A-Z]{16}/g },
|
|
14
|
+
{ name: 'aws-secret', re: /(?<![A-Za-z0-9])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9])/g },
|
|
15
|
+
{ name: 'pem-block', re: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g },
|
|
16
|
+
{ name: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{36,255}/g },
|
|
17
|
+
{ name: 'slack-token', re: /xox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
18
|
+
{ name: 'bearer', re: /Bearer\s+[A-Za-z0-9._\-+/=]{16,}/gi },
|
|
19
|
+
{ name: 'jwt', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
20
|
+
];
|
|
21
|
+
const REPLACEMENT = '[REDACTED]';
|
|
22
|
+
/** Redact a single string (or Buffer). */
|
|
23
|
+
export function redact(input) {
|
|
24
|
+
const s = typeof input === 'string' ? input : input.toString('utf-8');
|
|
25
|
+
let out = s;
|
|
26
|
+
for (const { name, re } of PATTERNS) {
|
|
27
|
+
out = out.replace(re, `${REPLACEMENT}:${name}`);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Streaming transform that redacts as bytes flow through. Useful for
|
|
33
|
+
* piping shell stdout/stderr into the transcript without buffering the
|
|
34
|
+
* full output in memory.
|
|
35
|
+
*/
|
|
36
|
+
export function createRedactor() {
|
|
37
|
+
let buf = '';
|
|
38
|
+
return new Transform({
|
|
39
|
+
transform(chunk, _enc, cb) {
|
|
40
|
+
buf += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
|
|
41
|
+
// Keep last 1 KiB un-flushed in case a multi-byte pattern straddles
|
|
42
|
+
// chunk boundaries.
|
|
43
|
+
const flushEnd = Math.max(0, buf.length - 1024);
|
|
44
|
+
const flush = buf.slice(0, flushEnd);
|
|
45
|
+
buf = buf.slice(flushEnd);
|
|
46
|
+
cb(null, redact(flush));
|
|
47
|
+
},
|
|
48
|
+
flush(cb) {
|
|
49
|
+
cb(null, redact(buf));
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider detection and configuration for the REPL.
|
|
3
|
+
*
|
|
4
|
+
* Supports any OpenAI-compatible `/chat/completions` endpoint. When no API
|
|
5
|
+
* key is set, the REPL probes a list of well-known local servers (Ollama,
|
|
6
|
+
* LM Studio, vLLM, llama.cpp) and uses whichever responds first.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order:
|
|
9
|
+
* 1. KLYRO_BASE_URL + KLYRO_API_KEY (full manual control)
|
|
10
|
+
* 2. KLYRO_BASE_URL alone (assume local server, no auth)
|
|
11
|
+
* 3. Auto-detect a local server
|
|
12
|
+
* 4. Fall back to api.openai.com with a clear error if no key
|
|
13
|
+
*/
|
|
14
|
+
export interface ProviderConfig {
|
|
15
|
+
baseURL: string;
|
|
16
|
+
apiKey: string;
|
|
17
|
+
model: string;
|
|
18
|
+
source: 'env' | 'local-probe' | 'manual';
|
|
19
|
+
}
|
|
20
|
+
/** Resolve which provider to use. Does not throw; returns `null` if nothing is reachable. */
|
|
21
|
+
export declare function resolveProvider(): Promise<ProviderConfig | null>;
|
|
22
|
+
/** Pretty-print a hint about how to configure the provider. */
|
|
23
|
+
export declare function providerHelp(p: ProviderConfig | null): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider detection and configuration for the REPL.
|
|
3
|
+
*
|
|
4
|
+
* Supports any OpenAI-compatible `/chat/completions` endpoint. When no API
|
|
5
|
+
* key is set, the REPL probes a list of well-known local servers (Ollama,
|
|
6
|
+
* LM Studio, vLLM, llama.cpp) and uses whichever responds first.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order:
|
|
9
|
+
* 1. KLYRO_BASE_URL + KLYRO_API_KEY (full manual control)
|
|
10
|
+
* 2. KLYRO_BASE_URL alone (assume local server, no auth)
|
|
11
|
+
* 3. Auto-detect a local server
|
|
12
|
+
* 4. Fall back to api.openai.com with a clear error if no key
|
|
13
|
+
*/
|
|
14
|
+
import { assertSafeBaseURL, normalizeBaseURL } from './chat.js';
|
|
15
|
+
const LOCAL_ENDPOINTS = [
|
|
16
|
+
{ name: 'Ollama', baseURL: 'http://localhost:11434/v1', defaultModel: 'llama3.2' },
|
|
17
|
+
{ name: 'LM Studio', baseURL: 'http://localhost:1234/v1', defaultModel: 'local-model' },
|
|
18
|
+
{ name: 'vLLM', baseURL: 'http://localhost:8000/v1', defaultModel: 'meta-llama/Llama-3-8B-Instruct' },
|
|
19
|
+
{ name: 'llama.cpp', baseURL: 'http://localhost:8080/v1', defaultModel: 'local-model' },
|
|
20
|
+
];
|
|
21
|
+
/** Resolve which provider to use. Does not throw; returns `null` if nothing is reachable. */
|
|
22
|
+
export async function resolveProvider() {
|
|
23
|
+
const envBaseURL = process.env.KLYRO_BASE_URL;
|
|
24
|
+
const envKey = process.env.KLYRO_API_KEY;
|
|
25
|
+
const envModel = process.env.KLYRO_MODEL;
|
|
26
|
+
if (envBaseURL) {
|
|
27
|
+
assertSafeBaseURL(envBaseURL);
|
|
28
|
+
return {
|
|
29
|
+
baseURL: normalizeBaseURL(envBaseURL),
|
|
30
|
+
apiKey: envKey ?? '',
|
|
31
|
+
model: envModel ?? 'gpt-4o-mini',
|
|
32
|
+
source: 'env',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (envKey) {
|
|
36
|
+
return {
|
|
37
|
+
baseURL: normalizeBaseURL('https://api.openai.com/v1'),
|
|
38
|
+
apiKey: envKey,
|
|
39
|
+
model: envModel ?? 'gpt-4o-mini',
|
|
40
|
+
source: 'manual',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Probe local endpoints
|
|
44
|
+
for (const ep of LOCAL_ENDPOINTS) {
|
|
45
|
+
if (await probeLocal(ep.baseURL)) {
|
|
46
|
+
return {
|
|
47
|
+
baseURL: ep.baseURL,
|
|
48
|
+
apiKey: '',
|
|
49
|
+
model: envModel ?? ep.defaultModel,
|
|
50
|
+
source: 'local-probe',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** Hit a cheap endpoint to check if a local server is up. */
|
|
57
|
+
async function probeLocal(baseURL) {
|
|
58
|
+
try {
|
|
59
|
+
const ctrl = new AbortController();
|
|
60
|
+
const t = setTimeout(() => ctrl.abort(), 600);
|
|
61
|
+
// Try the OpenAI-style /models endpoint; some servers (Ollama before load)
|
|
62
|
+
// answer /, others only /v1/models.
|
|
63
|
+
const res = await fetch(`${baseURL.replace(/\/+$/, '')}/models`, {
|
|
64
|
+
method: 'GET',
|
|
65
|
+
signal: ctrl.signal,
|
|
66
|
+
});
|
|
67
|
+
clearTimeout(t);
|
|
68
|
+
return res.ok;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Pretty-print a hint about how to configure the provider. */
|
|
75
|
+
export function providerHelp(p) {
|
|
76
|
+
if (p?.source === 'local-probe') {
|
|
77
|
+
return `(connected to local ${p.baseURL}, model: ${p.model})`;
|
|
78
|
+
}
|
|
79
|
+
if (p?.source === 'env') {
|
|
80
|
+
return `(env: ${p.baseURL}, model: ${p.model})`;
|
|
81
|
+
}
|
|
82
|
+
return `(no provider configured — set KLYRO_BASE_URL + KLYRO_API_KEY, or run a local server like Ollama)`;
|
|
83
|
+
}
|
package/dist/repl.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive REPL. Reads prompts from stdin one line at a time.
|
|
3
|
+
*
|
|
4
|
+
* Conversation history lives in memory only; each new prompt is sent with the
|
|
5
|
+
* full history so the model can refer back to earlier turns.
|
|
6
|
+
*
|
|
7
|
+
* Type ':quit' (or Ctrl-C / Ctrl-D) to exit.
|
|
8
|
+
*/
|
|
9
|
+
export declare function repl(system: string): Promise<void>;
|
package/dist/repl.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive REPL. Reads prompts from stdin one line at a time.
|
|
3
|
+
*
|
|
4
|
+
* Conversation history lives in memory only; each new prompt is sent with the
|
|
5
|
+
* full history so the model can refer back to earlier turns.
|
|
6
|
+
*
|
|
7
|
+
* Type ':quit' (or Ctrl-C / Ctrl-D) to exit.
|
|
8
|
+
*/
|
|
9
|
+
import * as readline from 'node:readline/promises';
|
|
10
|
+
import { stdin, exit } from 'node:process';
|
|
11
|
+
import { streamToStdout, readBoundedText } from './chat.js';
|
|
12
|
+
import { resolveProvider, providerHelp } from './providers.js';
|
|
13
|
+
/** Cap conversation history so we don't blow the model's context window. */
|
|
14
|
+
const MAX_HISTORY_TURNS = 40;
|
|
15
|
+
/** Approximate char budget for the user side of history. */
|
|
16
|
+
const MAX_HISTORY_CHARS = 80_000;
|
|
17
|
+
export async function repl(system) {
|
|
18
|
+
const provider = await resolveProvider();
|
|
19
|
+
if (!provider) {
|
|
20
|
+
console.error('klyro: no provider available.');
|
|
21
|
+
console.error(' Set KLYRO_BASE_URL and KLYRO_API_KEY, or run a local server (Ollama, LM Studio, vLLM).');
|
|
22
|
+
console.error(' Examples:');
|
|
23
|
+
console.error(' set KLYRO_BASE_URL=https://api.openai.com/v1');
|
|
24
|
+
console.error(' set KLYRO_API_KEY=sk-...');
|
|
25
|
+
console.error(' ollama serve # then KLYRO_BASE_URL=http://localhost:11434/v1 KLYRO_MODEL=llama3.2');
|
|
26
|
+
exit(2);
|
|
27
|
+
}
|
|
28
|
+
const { baseURL, apiKey, model, source } = provider;
|
|
29
|
+
const history = [];
|
|
30
|
+
// Adapt readline to the environment. On a TTY we let readline manage the
|
|
31
|
+
// prompt and echo so the user sees their typing; on a pipe we suppress both
|
|
32
|
+
// because the consumer is a script, not a human.
|
|
33
|
+
const isTTY = Boolean(stdin.isTTY);
|
|
34
|
+
const rl = readline.createInterface({
|
|
35
|
+
input: stdin,
|
|
36
|
+
output: isTTY ? process.stdout : undefined,
|
|
37
|
+
terminal: isTTY,
|
|
38
|
+
});
|
|
39
|
+
// Ctrl-C: when readline is in TTY mode it intercepts SIGINT and emits a
|
|
40
|
+
// 'SIGINT' event on the interface. Wire it to a clean exit so the user gets
|
|
41
|
+
// exit code 130 (the conventional 128+SIGINT(2)) instead of a crash.
|
|
42
|
+
rl.on('SIGINT', () => {
|
|
43
|
+
process.stdout.write('\n');
|
|
44
|
+
rl.close();
|
|
45
|
+
exit(130);
|
|
46
|
+
});
|
|
47
|
+
if (isTTY)
|
|
48
|
+
console.log(`klyro REPL — type :quit to exit, :clear to reset history ${providerHelp(provider)}`);
|
|
49
|
+
try {
|
|
50
|
+
while (true) {
|
|
51
|
+
// In TTY mode, readline handles the prompt and echoes the user's typing.
|
|
52
|
+
// In non-TTY mode, we write the prompt ourselves.
|
|
53
|
+
if (!isTTY)
|
|
54
|
+
process.stdout.write('> ');
|
|
55
|
+
let line;
|
|
56
|
+
try {
|
|
57
|
+
// Pass the prompt only when readline is in charge (TTY). In non-TTY
|
|
58
|
+
// mode the prompt is suppressed (output: undefined) so passing '' is
|
|
59
|
+
// equivalent and avoids doubling.
|
|
60
|
+
line = await rl.question(isTTY ? '> ' : '');
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
// EOF on stdin (Ctrl-D) or close — exit cleanly.
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (!line)
|
|
67
|
+
break; // empty line on EOF
|
|
68
|
+
const prompt = line.trim();
|
|
69
|
+
if (!prompt)
|
|
70
|
+
continue;
|
|
71
|
+
if (prompt === ':quit' || prompt === ':exit')
|
|
72
|
+
break;
|
|
73
|
+
if (prompt === ':clear') {
|
|
74
|
+
history.length = 0;
|
|
75
|
+
if (isTTY)
|
|
76
|
+
console.log('(history cleared)');
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
history.push({ role: 'user', content: prompt });
|
|
80
|
+
trimHistory(history);
|
|
81
|
+
const reply = await ask(baseURL, apiKey, model, system, history);
|
|
82
|
+
history.push({ role: 'assistant', content: reply });
|
|
83
|
+
// streamToStdout already ends with a newline; no extra needed.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
rl.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Drop oldest turns to stay under the size cap. */
|
|
91
|
+
function trimHistory(history) {
|
|
92
|
+
while (history.length > MAX_HISTORY_TURNS)
|
|
93
|
+
history.shift();
|
|
94
|
+
let total = 0;
|
|
95
|
+
for (const t of history)
|
|
96
|
+
total += t.content.length;
|
|
97
|
+
while (total > MAX_HISTORY_CHARS && history.length > 2) {
|
|
98
|
+
const head = history[0];
|
|
99
|
+
if (!head)
|
|
100
|
+
break;
|
|
101
|
+
total -= head.content.length;
|
|
102
|
+
history.shift();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Send the full history, stream the reply, return the assistant text. */
|
|
106
|
+
async function ask(baseURL, apiKey, model, system, history) {
|
|
107
|
+
const ac = new AbortController();
|
|
108
|
+
const timeoutMs = Number(process.env.KLYRO_TIMEOUT_MS) || 60_000;
|
|
109
|
+
const t = setTimeout(() => ac.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
110
|
+
let res;
|
|
111
|
+
try {
|
|
112
|
+
const headers = {
|
|
113
|
+
'Content-Type': 'application/json',
|
|
114
|
+
};
|
|
115
|
+
// Only attach Authorization when a key is configured. Local servers
|
|
116
|
+
// (Ollama, LM Studio) accept requests without auth.
|
|
117
|
+
if (apiKey) {
|
|
118
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
119
|
+
}
|
|
120
|
+
res = await fetch(`${baseURL.replace(/\/+$/, '')}/chat/completions`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers,
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
model,
|
|
125
|
+
// System message once, at the head of the messages array.
|
|
126
|
+
messages: [{ role: 'system', content: system }, ...history],
|
|
127
|
+
stream: true,
|
|
128
|
+
}),
|
|
129
|
+
signal: ac.signal,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
clearTimeout(t);
|
|
134
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
135
|
+
console.error(`\nklyro: request failed: ${msg}`);
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
clearTimeout(t);
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
const text = await readBoundedText(res.body, 4000);
|
|
141
|
+
console.error(`\nklyro: HTTP ${res.status} ${res.statusText}\n${text}`);
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
if (!res.body) {
|
|
145
|
+
console.error('\nklyro: no response body');
|
|
146
|
+
return '';
|
|
147
|
+
}
|
|
148
|
+
// Use the shared streamToStdout from chat.ts. Capture what we wrote so we
|
|
149
|
+
// can return it for the conversation history.
|
|
150
|
+
const collected = [];
|
|
151
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
152
|
+
process.stdout.write = (chunk) => {
|
|
153
|
+
if (typeof chunk === 'string')
|
|
154
|
+
collected.push(chunk);
|
|
155
|
+
return origWrite(chunk);
|
|
156
|
+
};
|
|
157
|
+
try {
|
|
158
|
+
await streamToStdout(res.body, ac.signal);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
if (!ac.signal.aborted) {
|
|
162
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
163
|
+
console.error(`\nklyro: stream error: ${msg}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
process.stdout.write = origWrite;
|
|
168
|
+
}
|
|
169
|
+
return collected.join('');
|
|
170
|
+
}
|
package/dist/repl.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repl.js","sourceRoot":"","sources":["../src/repl.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit_file — find-and-replace in an existing file.
|
|
3
|
+
* Strict: ambiguous matches fail unless replaceAll=true. Missing match fails.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
declare const InputSchema: z.ZodObject<{
|
|
7
|
+
path: z.ZodString;
|
|
8
|
+
find: z.ZodString;
|
|
9
|
+
replace: z.ZodString;
|
|
10
|
+
replaceAll: z.ZodOptional<z.ZodBoolean>;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
export interface EditFileOutput {
|
|
13
|
+
path: string;
|
|
14
|
+
replacements: number;
|
|
15
|
+
diff: string;
|
|
16
|
+
}
|
|
17
|
+
export declare const editFileTool: import("../types.js").Tool<{
|
|
18
|
+
path: string;
|
|
19
|
+
find: string;
|
|
20
|
+
replace: string;
|
|
21
|
+
replaceAll?: boolean | undefined;
|
|
22
|
+
}, {
|
|
23
|
+
readonly ok: false;
|
|
24
|
+
readonly error: {
|
|
25
|
+
readonly code: "MATCH_NOT_FOUND";
|
|
26
|
+
readonly message: `find substring not present in ${string}`;
|
|
27
|
+
};
|
|
28
|
+
path?: undefined;
|
|
29
|
+
replacements?: undefined;
|
|
30
|
+
diff?: undefined;
|
|
31
|
+
} | {
|
|
32
|
+
readonly ok: false;
|
|
33
|
+
readonly error: {
|
|
34
|
+
readonly code: "MATCH_AMBIGUOUS";
|
|
35
|
+
readonly message: `find substring occurs ${number} times in ${string}. Supply more context or pass replaceAll=true.`;
|
|
36
|
+
};
|
|
37
|
+
path?: undefined;
|
|
38
|
+
replacements?: undefined;
|
|
39
|
+
diff?: undefined;
|
|
40
|
+
} | {
|
|
41
|
+
path: string;
|
|
42
|
+
replacements: number;
|
|
43
|
+
diff: string;
|
|
44
|
+
readonly ok?: undefined;
|
|
45
|
+
readonly error?: undefined;
|
|
46
|
+
}>;
|
|
47
|
+
export type EditFileInput = z.infer<typeof InputSchema>;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit_file — find-and-replace in an existing file.
|
|
3
|
+
* Strict: ambiguous matches fail unless replaceAll=true. Missing match fails.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { defineTool } from '../types.js';
|
|
8
|
+
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
9
|
+
import { safe } from '../normalize.js';
|
|
10
|
+
const InputSchema = z.object({
|
|
11
|
+
path: z.string().min(1),
|
|
12
|
+
find: z.string().min(1).describe('Exact substring to match. Not a regex.'),
|
|
13
|
+
replace: z.string(),
|
|
14
|
+
replaceAll: z.boolean().optional().describe('If true, replace every occurrence.'),
|
|
15
|
+
});
|
|
16
|
+
export const editFileTool = defineTool({
|
|
17
|
+
name: 'edit_file',
|
|
18
|
+
description: 'Replace a substring in a file. Strict: if `find` is missing or ambiguous, the edit fails (unless replaceAll=true).',
|
|
19
|
+
inputSchema: InputSchema,
|
|
20
|
+
execute: async (input, ctx) => {
|
|
21
|
+
return safe(async () => {
|
|
22
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
|
|
23
|
+
const original = await fs.readFile(resolved, 'utf-8');
|
|
24
|
+
const count = countOccurrences(original, input.find);
|
|
25
|
+
if (count === 0) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
error: { code: 'MATCH_NOT_FOUND', message: `find substring not present in ${input.path}` },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const replaceAll = input.replaceAll === true;
|
|
32
|
+
if (!replaceAll && count > 1) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: {
|
|
36
|
+
code: 'MATCH_AMBIGUOUS',
|
|
37
|
+
message: `find substring occurs ${count} times in ${input.path}. Supply more context or pass replaceAll=true.`,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const next = replaceAll ? original.split(input.find).join(input.replace) : original.replace(input.find, input.replace);
|
|
42
|
+
const tmp = `${resolved}.klyro-edit-${process.pid}-${Date.now()}.tmp`;
|
|
43
|
+
await fs.writeFile(tmp, next, 'utf-8');
|
|
44
|
+
try {
|
|
45
|
+
await fs.rename(tmp, resolved);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
path: input.path,
|
|
53
|
+
replacements: replaceAll ? count : 1,
|
|
54
|
+
diff: simpleDiff(original, input.find, input.replace, replaceAll ? count : 1),
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
function countOccurrences(haystack, needle) {
|
|
60
|
+
if (needle.length === 0)
|
|
61
|
+
return 0;
|
|
62
|
+
let count = 0;
|
|
63
|
+
let from = 0;
|
|
64
|
+
while (true) {
|
|
65
|
+
const i = haystack.indexOf(needle, from);
|
|
66
|
+
if (i < 0)
|
|
67
|
+
break;
|
|
68
|
+
count++;
|
|
69
|
+
from = i + needle.length;
|
|
70
|
+
}
|
|
71
|
+
return count;
|
|
72
|
+
}
|
|
73
|
+
function simpleDiff(original, find, replace, count) {
|
|
74
|
+
const firstIdx = original.indexOf(find);
|
|
75
|
+
if (firstIdx < 0)
|
|
76
|
+
return '';
|
|
77
|
+
const start = Math.max(0, original.lastIndexOf('\n', firstIdx) + 1);
|
|
78
|
+
const endLf = original.indexOf('\n', firstIdx + find.length);
|
|
79
|
+
const end = endLf < 0 ? original.length : endLf;
|
|
80
|
+
const beforeSnippet = original.slice(start, end);
|
|
81
|
+
const afterSnippet = beforeSnippet.split(find).join(replace);
|
|
82
|
+
const more = count > 1 ? `\n... and ${count - 1} more replacement(s)` : '';
|
|
83
|
+
return `--- before\n${beforeSnippet}\n+++ after\n${afterSnippet}${more}`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list_directory — list entries inside a directory.
|
|
3
|
+
*
|
|
4
|
+
* Refuses paths outside cwd. Skips node_modules and .git by default.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
declare const InputSchema: z.ZodObject<{
|
|
8
|
+
path: z.ZodDefault<z.ZodString>;
|
|
9
|
+
maxDepth: z.ZodOptional<z.ZodNumber>;
|
|
10
|
+
skip: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
export interface ListEntry {
|
|
13
|
+
name: string;
|
|
14
|
+
type: 'file' | 'directory' | 'symlink' | 'other';
|
|
15
|
+
size: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ListDirOutput {
|
|
18
|
+
path: string;
|
|
19
|
+
entries: ListEntry[];
|
|
20
|
+
}
|
|
21
|
+
export declare const listDirTool: import("../types.js").Tool<{
|
|
22
|
+
path: string;
|
|
23
|
+
maxDepth?: number | undefined;
|
|
24
|
+
skip?: string[] | undefined;
|
|
25
|
+
}, {
|
|
26
|
+
readonly ok: false;
|
|
27
|
+
readonly error: {
|
|
28
|
+
readonly code: "INVALID_INPUT";
|
|
29
|
+
readonly message: `Not a directory: ${string}`;
|
|
30
|
+
};
|
|
31
|
+
path?: undefined;
|
|
32
|
+
entries?: undefined;
|
|
33
|
+
} | {
|
|
34
|
+
path: string;
|
|
35
|
+
entries: ListEntry[];
|
|
36
|
+
readonly ok?: undefined;
|
|
37
|
+
readonly error?: undefined;
|
|
38
|
+
}>;
|
|
39
|
+
export type ListDirInput = z.infer<typeof InputSchema>;
|
|
40
|
+
export {};
|