twinclaw 1.0.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/README.md +66 -0
- package/bin/npm-twinclaw.js +17 -0
- package/bin/run-twinbot-cli.js +36 -0
- package/bin/twinbot.js +4 -0
- package/bin/twinclaw.js +4 -0
- package/dist/api/handlers/browser.js +160 -0
- package/dist/api/handlers/callback.js +80 -0
- package/dist/api/handlers/config-validate.js +19 -0
- package/dist/api/handlers/health.js +117 -0
- package/dist/api/handlers/local-state-backup.js +118 -0
- package/dist/api/handlers/persona-state.js +59 -0
- package/dist/api/handlers/skill-packages.js +94 -0
- package/dist/api/router.js +278 -0
- package/dist/api/runtime-event-producer.js +99 -0
- package/dist/api/shared.js +82 -0
- package/dist/api/websocket-hub.js +305 -0
- package/dist/config/config-loader.js +2 -0
- package/dist/config/env-schema.js +202 -0
- package/dist/config/env-validator.js +223 -0
- package/dist/config/identity-bootstrap.js +115 -0
- package/dist/config/json-config.js +344 -0
- package/dist/config/workspace.js +186 -0
- package/dist/core/channels-cli.js +77 -0
- package/dist/core/cli.js +119 -0
- package/dist/core/context-assembly.js +33 -0
- package/dist/core/doctor.js +365 -0
- package/dist/core/gateway-cli.js +323 -0
- package/dist/core/gateway.js +416 -0
- package/dist/core/heartbeat.js +54 -0
- package/dist/core/install-cli.js +320 -0
- package/dist/core/lane-executor.js +134 -0
- package/dist/core/logs-cli.js +70 -0
- package/dist/core/onboarding.js +760 -0
- package/dist/core/pairing-cli.js +78 -0
- package/dist/core/secret-vault-cli.js +204 -0
- package/dist/core/types.js +1 -0
- package/dist/index.js +404 -0
- package/dist/interfaces/dispatcher.js +214 -0
- package/dist/interfaces/telegram_handler.js +82 -0
- package/dist/interfaces/tui-dashboard.js +53 -0
- package/dist/interfaces/whatsapp_handler.js +94 -0
- package/dist/release/cli.js +97 -0
- package/dist/release/mvp-gate-cli.js +118 -0
- package/dist/release/twinbot-config-schema.js +162 -0
- package/dist/release/twinclaw-config-schema.js +162 -0
- package/dist/services/block-chunker.js +174 -0
- package/dist/services/browser-service.js +334 -0
- package/dist/services/context-lifecycle.js +314 -0
- package/dist/services/db.js +1055 -0
- package/dist/services/delivery-tracker.js +110 -0
- package/dist/services/dm-pairing.js +245 -0
- package/dist/services/embedding-service.js +125 -0
- package/dist/services/file-watcher.js +125 -0
- package/dist/services/inbound-debounce.js +92 -0
- package/dist/services/incident-manager.js +516 -0
- package/dist/services/job-scheduler.js +176 -0
- package/dist/services/local-state-backup.js +682 -0
- package/dist/services/mcp-client-adapter.js +291 -0
- package/dist/services/mcp-server-manager.js +143 -0
- package/dist/services/model-router.js +927 -0
- package/dist/services/mvp-gate.js +845 -0
- package/dist/services/orchestration-service.js +422 -0
- package/dist/services/persona-state.js +256 -0
- package/dist/services/policy-engine.js +92 -0
- package/dist/services/proactive-notifier.js +94 -0
- package/dist/services/queue-service.js +146 -0
- package/dist/services/release-pipeline.js +652 -0
- package/dist/services/runtime-budget-governor.js +415 -0
- package/dist/services/secret-vault.js +704 -0
- package/dist/services/semantic-memory.js +249 -0
- package/dist/services/skill-package-manager.js +806 -0
- package/dist/services/skill-registry.js +122 -0
- package/dist/services/streaming-output.js +75 -0
- package/dist/services/stt-service.js +39 -0
- package/dist/services/tts-service.js +44 -0
- package/dist/skills/builtin.js +250 -0
- package/dist/skills/shell.js +87 -0
- package/dist/skills/types.js +1 -0
- package/dist/types/api.js +1 -0
- package/dist/types/context-budget.js +1 -0
- package/dist/types/doctor.js +1 -0
- package/dist/types/file-watcher.js +1 -0
- package/dist/types/incident.js +1 -0
- package/dist/types/local-state-backup.js +1 -0
- package/dist/types/mcp.js +1 -0
- package/dist/types/messaging.js +1 -0
- package/dist/types/model-routing.js +1 -0
- package/dist/types/mvp-gate.js +2 -0
- package/dist/types/orchestration.js +1 -0
- package/dist/types/persona-state.js +22 -0
- package/dist/types/policy.js +1 -0
- package/dist/types/reasoning-graph.js +1 -0
- package/dist/types/release.js +1 -0
- package/dist/types/reliability.js +1 -0
- package/dist/types/runtime-budget.js +1 -0
- package/dist/types/scheduler.js +1 -0
- package/dist/types/secret-vault.js +1 -0
- package/dist/types/skill-packages.js +1 -0
- package/dist/types/websocket.js +14 -0
- package/dist/utils/logger.js +57 -0
- package/dist/utils/retry.js +61 -0
- package/dist/utils/secret-scan.js +208 -0
- package/mcp-servers.json +179 -0
- package/package.json +81 -0
- package/skill-packages.json +92 -0
- package/skill-packages.lock.json +5 -0
- package/src/skills/builtin.ts +275 -0
- package/src/skills/shell.ts +118 -0
- package/src/skills/types.ts +30 -0
- package/src/types/api.ts +252 -0
- package/src/types/blessed-contrib.d.ts +4 -0
- package/src/types/context-budget.ts +76 -0
- package/src/types/doctor.ts +29 -0
- package/src/types/file-watcher.ts +26 -0
- package/src/types/incident.ts +57 -0
- package/src/types/local-state-backup.ts +121 -0
- package/src/types/mcp.ts +106 -0
- package/src/types/messaging.ts +35 -0
- package/src/types/model-routing.ts +61 -0
- package/src/types/mvp-gate.ts +99 -0
- package/src/types/orchestration.ts +65 -0
- package/src/types/persona-state.ts +61 -0
- package/src/types/policy.ts +27 -0
- package/src/types/reasoning-graph.ts +58 -0
- package/src/types/release.ts +115 -0
- package/src/types/reliability.ts +43 -0
- package/src/types/runtime-budget.ts +85 -0
- package/src/types/scheduler.ts +47 -0
- package/src/types/secret-vault.ts +62 -0
- package/src/types/skill-packages.ts +81 -0
- package/src/types/sqlite-vec.d.ts +5 -0
- package/src/types/websocket.ts +122 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { logThought } from '../utils/logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* Centralized skill registry for TwinBot.
|
|
4
|
+
*
|
|
5
|
+
* Serves as a single catalog for all available tools — both local builtins
|
|
6
|
+
* and MCP-backed remote skills. The lane executor queries this registry
|
|
7
|
+
* to build the tool list sent to the model router.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```ts
|
|
11
|
+
* const registry = new SkillRegistry();
|
|
12
|
+
* registry.registerMany(createBuiltinSkills());
|
|
13
|
+
* registry.register(mcpBackedSkill);
|
|
14
|
+
* const allTools = registry.list();
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export class SkillRegistry {
|
|
18
|
+
#skills = new Map();
|
|
19
|
+
#aliases = new Map();
|
|
20
|
+
#removeAliasesForCanonical(canonicalName) {
|
|
21
|
+
for (const [alias, mappedCanonical] of this.#aliases.entries()) {
|
|
22
|
+
if (mappedCanonical === canonicalName) {
|
|
23
|
+
this.#aliases.delete(alias);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
#resolveCanonicalName(name) {
|
|
28
|
+
return this.#aliases.get(name) ?? name;
|
|
29
|
+
}
|
|
30
|
+
/** Register a single skill. Overwrites if a skill with the same name already exists. */
|
|
31
|
+
register(skill) {
|
|
32
|
+
const source = skill.source ?? 'builtin';
|
|
33
|
+
const canonicalName = skill.name;
|
|
34
|
+
this.#removeAliasesForCanonical(canonicalName);
|
|
35
|
+
const normalized = { ...skill, source };
|
|
36
|
+
this.#skills.set(canonicalName, normalized);
|
|
37
|
+
for (const alias of skill.aliases ?? []) {
|
|
38
|
+
const normalizedAlias = alias.trim();
|
|
39
|
+
if (!normalizedAlias || normalizedAlias === canonicalName) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (this.#skills.has(normalizedAlias)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
this.#aliases.set(normalizedAlias, canonicalName);
|
|
46
|
+
}
|
|
47
|
+
void logThought(`[SkillRegistry] Registered skill '${skill.name}' (source: ${source}, aliases: ${skill.aliases?.length ?? 0}).`);
|
|
48
|
+
}
|
|
49
|
+
/** Register multiple skills at once. */
|
|
50
|
+
registerMany(skills) {
|
|
51
|
+
for (const skill of skills) {
|
|
52
|
+
this.register(skill);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Unregister a skill by name. Returns true if the skill was found and removed. */
|
|
56
|
+
unregister(name) {
|
|
57
|
+
const canonicalName = this.#resolveCanonicalName(name);
|
|
58
|
+
const removed = this.#skills.delete(canonicalName);
|
|
59
|
+
if (removed) {
|
|
60
|
+
this.#removeAliasesForCanonical(canonicalName);
|
|
61
|
+
}
|
|
62
|
+
return removed;
|
|
63
|
+
}
|
|
64
|
+
/** Unregister all skills from a specific MCP server. */
|
|
65
|
+
unregisterByServer(serverId) {
|
|
66
|
+
const namesToRemove = [];
|
|
67
|
+
for (const [name, skill] of this.#skills.entries()) {
|
|
68
|
+
if (skill.serverId === serverId) {
|
|
69
|
+
namesToRemove.push(name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const name of namesToRemove) {
|
|
73
|
+
this.unregister(name);
|
|
74
|
+
}
|
|
75
|
+
return namesToRemove.length;
|
|
76
|
+
}
|
|
77
|
+
/** Look up a skill by its unique name. */
|
|
78
|
+
get(name) {
|
|
79
|
+
return this.#skills.get(this.#resolveCanonicalName(name));
|
|
80
|
+
}
|
|
81
|
+
/** Check if a skill with the given name exists in the registry. */
|
|
82
|
+
has(name) {
|
|
83
|
+
return this.get(name) !== undefined;
|
|
84
|
+
}
|
|
85
|
+
/** List all registered skills, optionally filtered by source or server. */
|
|
86
|
+
list(filter) {
|
|
87
|
+
const all = [...this.#skills.values()];
|
|
88
|
+
if (!filter)
|
|
89
|
+
return all;
|
|
90
|
+
return all.filter((skill) => {
|
|
91
|
+
if (filter.source && skill.source !== filter.source)
|
|
92
|
+
return false;
|
|
93
|
+
if (filter.serverId && skill.serverId !== filter.serverId)
|
|
94
|
+
return false;
|
|
95
|
+
if (filter.group && skill.group !== filter.group)
|
|
96
|
+
return false;
|
|
97
|
+
return true;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** Return the total number of registered skills. */
|
|
101
|
+
get size() {
|
|
102
|
+
return this.#skills.size;
|
|
103
|
+
}
|
|
104
|
+
/** Return a summary of skills grouped by source. */
|
|
105
|
+
summary() {
|
|
106
|
+
const counts = {
|
|
107
|
+
builtin: 0,
|
|
108
|
+
mcp: 0,
|
|
109
|
+
groups: {},
|
|
110
|
+
};
|
|
111
|
+
for (const skill of this.#skills.values()) {
|
|
112
|
+
const source = skill.source ?? 'builtin';
|
|
113
|
+
if (source === 'builtin' || source === 'mcp') {
|
|
114
|
+
counts[source] += 1;
|
|
115
|
+
}
|
|
116
|
+
if (skill.group) {
|
|
117
|
+
counts.groups[skill.group] = (counts.groups[skill.group] ?? 0) + 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return counts;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { EmbeddedBlockChunker } from './block-chunker.js';
|
|
2
|
+
const DEFAULT_HUMAN_DELAY_MS = 800;
|
|
3
|
+
export class StreamingOutputService {
|
|
4
|
+
#enabled;
|
|
5
|
+
#humanDelayMs;
|
|
6
|
+
#chunker;
|
|
7
|
+
#queue;
|
|
8
|
+
#now;
|
|
9
|
+
#sleep;
|
|
10
|
+
#activeStreams = new Map();
|
|
11
|
+
constructor(queue, options = {}) {
|
|
12
|
+
this.#queue = queue;
|
|
13
|
+
this.#enabled = options.enabled ?? true;
|
|
14
|
+
this.#humanDelayMs = Math.max(0, Math.floor(options.humanDelayMs ?? DEFAULT_HUMAN_DELAY_MS));
|
|
15
|
+
this.#chunker = new EmbeddedBlockChunker(options.chunkerOptions);
|
|
16
|
+
this.#now = options.now ?? (() => Date.now());
|
|
17
|
+
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
18
|
+
}
|
|
19
|
+
get enabled() {
|
|
20
|
+
return this.#enabled;
|
|
21
|
+
}
|
|
22
|
+
startStream(streamId, platform, chatId) {
|
|
23
|
+
this.#activeStreams.set(streamId, {
|
|
24
|
+
platform,
|
|
25
|
+
chatId,
|
|
26
|
+
buffer: '',
|
|
27
|
+
chunks: [],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
handleDelta(streamId, delta) {
|
|
31
|
+
const context = this.#activeStreams.get(streamId);
|
|
32
|
+
if (!context)
|
|
33
|
+
return;
|
|
34
|
+
if (delta.type === 'text_delta' && delta.content) {
|
|
35
|
+
context.buffer += delta.content;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async finalizeStream(streamId) {
|
|
39
|
+
const context = this.#activeStreams.get(streamId);
|
|
40
|
+
if (!context)
|
|
41
|
+
return;
|
|
42
|
+
this.#activeStreams.delete(streamId);
|
|
43
|
+
const fullText = context.buffer.trim();
|
|
44
|
+
if (!fullText)
|
|
45
|
+
return;
|
|
46
|
+
if (!this.#enabled) {
|
|
47
|
+
this.#queue.enqueue(context.platform, context.chatId, fullText);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const chunks = this.#chunker.chunk(fullText);
|
|
51
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
52
|
+
const chunk = chunks[i];
|
|
53
|
+
const closedChunk = EmbeddedBlockChunker.ensureCodeFenceClosed(chunk);
|
|
54
|
+
if (i > 0 && this.#humanDelayMs > 0) {
|
|
55
|
+
await this.#sleep(this.#humanDelayMs);
|
|
56
|
+
}
|
|
57
|
+
this.#queue.enqueue(context.platform, context.chatId, closedChunk);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async streamAndDispatch(streamId, platform, chatId, streamFn) {
|
|
61
|
+
this.startStream(streamId, platform, chatId);
|
|
62
|
+
try {
|
|
63
|
+
await streamFn((delta) => this.handleDelta(streamId, delta));
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await this.finalizeStream(streamId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
getActiveStreamCount() {
|
|
70
|
+
return this.#activeStreams.size;
|
|
71
|
+
}
|
|
72
|
+
getChunker() {
|
|
73
|
+
return this.#chunker;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Groq from 'groq-sdk';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
const DEFAULT_MODEL = 'whisper-large-v3-turbo';
|
|
4
|
+
/**
|
|
5
|
+
* Speech-to-Text service backed by Groq's hosted Whisper API (free tier).
|
|
6
|
+
*
|
|
7
|
+
* Multimodal queuing contract: transcription is fully awaited before the
|
|
8
|
+
* caller receives the text, ensuring audio processing completes before any
|
|
9
|
+
* downstream text handling begins.
|
|
10
|
+
*/
|
|
11
|
+
export class SttService {
|
|
12
|
+
#client;
|
|
13
|
+
#model;
|
|
14
|
+
/**
|
|
15
|
+
* @param apiKey - Groq API key (GROQ_API_KEY).
|
|
16
|
+
* @param model - Whisper model variant; defaults to `whisper-large-v3-turbo`.
|
|
17
|
+
*/
|
|
18
|
+
constructor(apiKey, model = DEFAULT_MODEL) {
|
|
19
|
+
this.#client = new Groq({ apiKey });
|
|
20
|
+
this.#model = model;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Transcribe a local audio file to text.
|
|
24
|
+
*
|
|
25
|
+
* @param filePath - Absolute path to a supported audio file
|
|
26
|
+
* (flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm).
|
|
27
|
+
* @returns The transcribed text string.
|
|
28
|
+
* @throws If the Groq API call fails or the file cannot be read.
|
|
29
|
+
*/
|
|
30
|
+
async transcribeFile(filePath) {
|
|
31
|
+
const audioStream = createReadStream(filePath);
|
|
32
|
+
const result = await this.#client.audio.transcriptions.create({
|
|
33
|
+
file: audioStream,
|
|
34
|
+
model: this.#model,
|
|
35
|
+
response_format: 'json',
|
|
36
|
+
});
|
|
37
|
+
return result.text;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import Groq from 'groq-sdk';
|
|
2
|
+
const DEFAULT_MODEL_ID = 'canopylabs/orpheus-v1-english';
|
|
3
|
+
const DEFAULT_VOICE = 'autumn';
|
|
4
|
+
const DEFAULT_FORMAT = 'wav';
|
|
5
|
+
/**
|
|
6
|
+
* Text-to-Speech service backed by the Groq Audio Speech API.
|
|
7
|
+
*
|
|
8
|
+
* Returns a raw WAV Buffer that can be sent directly as a Telegram voice message
|
|
9
|
+
* or written to disk for further processing.
|
|
10
|
+
*/
|
|
11
|
+
export class TtsService {
|
|
12
|
+
#client;
|
|
13
|
+
#modelId;
|
|
14
|
+
#voice;
|
|
15
|
+
#outputFormat;
|
|
16
|
+
/**
|
|
17
|
+
* @param apiKey - Groq API key (GROQ_API_KEY).
|
|
18
|
+
* @param modelId - Model to use for synthesis; defaults to `canopylabs/orpheus-v1-english`.
|
|
19
|
+
* @param voice - Voice preset to use; defaults to `autumn`.
|
|
20
|
+
* @param outputFormat - Audio encoding; defaults to `wav`.
|
|
21
|
+
*/
|
|
22
|
+
constructor(apiKey, modelId = DEFAULT_MODEL_ID, voice = DEFAULT_VOICE, outputFormat = DEFAULT_FORMAT) {
|
|
23
|
+
this.#client = new Groq({ apiKey });
|
|
24
|
+
this.#modelId = modelId;
|
|
25
|
+
this.#voice = voice;
|
|
26
|
+
this.#outputFormat = outputFormat;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Convert text to a synthesized WAV audio buffer.
|
|
30
|
+
*
|
|
31
|
+
* @param text - The text to synthesize.
|
|
32
|
+
* @returns A Buffer containing the full WAV audio.
|
|
33
|
+
* @throws If the Groq API call fails.
|
|
34
|
+
*/
|
|
35
|
+
async synthesize(text) {
|
|
36
|
+
const wav = await this.#client.audio.speech.create({
|
|
37
|
+
model: this.#modelId,
|
|
38
|
+
voice: this.#voice,
|
|
39
|
+
response_format: this.#outputFormat,
|
|
40
|
+
input: text,
|
|
41
|
+
});
|
|
42
|
+
return Buffer.from(await wav.arrayBuffer());
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { appendFile, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { executeShell } from './shell.js';
|
|
6
|
+
import { logToolCall } from '../utils/logger.js';
|
|
7
|
+
function resolveWorkspacePath(inputPath) {
|
|
8
|
+
return path.resolve(process.cwd(), inputPath);
|
|
9
|
+
}
|
|
10
|
+
function quoteShellArg(value) {
|
|
11
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
12
|
+
}
|
|
13
|
+
function buildReadFileSkill() {
|
|
14
|
+
return {
|
|
15
|
+
name: 'fs.read',
|
|
16
|
+
group: 'group:fs',
|
|
17
|
+
aliases: ['read_file'],
|
|
18
|
+
description: 'Read a UTF-8 text file from disk.',
|
|
19
|
+
parameters: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
filePath: { type: 'string' },
|
|
23
|
+
},
|
|
24
|
+
required: ['filePath'],
|
|
25
|
+
},
|
|
26
|
+
async execute(input) {
|
|
27
|
+
const filePathValue = input.filePath;
|
|
28
|
+
if (typeof filePathValue !== 'string' || filePathValue.trim().length === 0) {
|
|
29
|
+
return { ok: false, output: 'filePath must be a non-empty string.' };
|
|
30
|
+
}
|
|
31
|
+
const absolutePath = resolveWorkspacePath(filePathValue);
|
|
32
|
+
const content = await readFile(absolutePath, 'utf8');
|
|
33
|
+
await logToolCall('fs.read', input, `Read ${content.length} chars from ${absolutePath}`);
|
|
34
|
+
return { ok: true, output: content };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function buildListFilesSkill() {
|
|
39
|
+
return {
|
|
40
|
+
name: 'fs.list',
|
|
41
|
+
group: 'group:fs',
|
|
42
|
+
aliases: ['list_files'],
|
|
43
|
+
description: 'List files and folders in a directory.',
|
|
44
|
+
parameters: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
dirPath: { type: 'string' },
|
|
48
|
+
},
|
|
49
|
+
required: [],
|
|
50
|
+
},
|
|
51
|
+
async execute(input) {
|
|
52
|
+
const dirPathValue = typeof input.dirPath === 'string' ? input.dirPath : '.';
|
|
53
|
+
const absolutePath = resolveWorkspacePath(dirPathValue);
|
|
54
|
+
const entries = await readdir(absolutePath, { withFileTypes: true });
|
|
55
|
+
const output = entries
|
|
56
|
+
.map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`)
|
|
57
|
+
.join('\n');
|
|
58
|
+
await logToolCall('fs.list', input, `Listed ${entries.length} entries from ${absolutePath}`);
|
|
59
|
+
return { ok: true, output };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function buildWriteFileSkill() {
|
|
64
|
+
return {
|
|
65
|
+
name: 'fs.write',
|
|
66
|
+
group: 'group:fs',
|
|
67
|
+
description: 'Write UTF-8 text content to a file path.',
|
|
68
|
+
parameters: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
filePath: { type: 'string' },
|
|
72
|
+
content: { type: 'string' },
|
|
73
|
+
append: { type: 'boolean' },
|
|
74
|
+
},
|
|
75
|
+
required: ['filePath', 'content'],
|
|
76
|
+
},
|
|
77
|
+
async execute(input) {
|
|
78
|
+
const filePathValue = input.filePath;
|
|
79
|
+
if (typeof filePathValue !== 'string' || filePathValue.trim().length === 0) {
|
|
80
|
+
return { ok: false, output: 'filePath must be a non-empty string.' };
|
|
81
|
+
}
|
|
82
|
+
const contentValue = input.content;
|
|
83
|
+
if (typeof contentValue !== 'string') {
|
|
84
|
+
return { ok: false, output: 'content must be a string.' };
|
|
85
|
+
}
|
|
86
|
+
const absolutePath = resolveWorkspacePath(filePathValue);
|
|
87
|
+
if (input.append === true) {
|
|
88
|
+
await appendFile(absolutePath, contentValue, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
await writeFile(absolutePath, contentValue, 'utf8');
|
|
92
|
+
}
|
|
93
|
+
await logToolCall('fs.write', input, `Wrote ${contentValue.length} chars to ${absolutePath}`);
|
|
94
|
+
return { ok: true, output: `Updated ${absolutePath}.` };
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function buildApplyPatchSkill() {
|
|
99
|
+
return {
|
|
100
|
+
name: 'fs.apply_patch',
|
|
101
|
+
group: 'group:fs',
|
|
102
|
+
aliases: ['apply_patch'],
|
|
103
|
+
description: 'Apply a unified diff patch to files in the current workspace using git apply.',
|
|
104
|
+
parameters: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
patch: { type: 'string' },
|
|
108
|
+
},
|
|
109
|
+
required: ['patch'],
|
|
110
|
+
},
|
|
111
|
+
async execute(input) {
|
|
112
|
+
const patchValue = input.patch;
|
|
113
|
+
if (typeof patchValue !== 'string' || patchValue.trim().length === 0) {
|
|
114
|
+
return { ok: false, output: 'patch must be a non-empty string.' };
|
|
115
|
+
}
|
|
116
|
+
const tempPatchPath = path.join(tmpdir(), `twinbot-${randomUUID()}.patch`);
|
|
117
|
+
try {
|
|
118
|
+
await writeFile(tempPatchPath, patchValue, 'utf8');
|
|
119
|
+
const result = await executeShell(`git apply --whitespace=nowarn ${quoteShellArg(tempPatchPath)}`, process.cwd(), { timeoutMs: 20_000 });
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
await logToolCall('fs.apply_patch', input, `Patch apply failed: ${result.output}`);
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
output: `Failed to apply patch: ${result.output || 'git apply returned a non-zero exit code.'}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
await logToolCall('fs.apply_patch', input, 'Patch applied successfully.');
|
|
128
|
+
return { ok: true, output: 'Patch applied successfully.' };
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await rm(tempPatchPath, { force: true }).catch(() => undefined);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function resolveShellOptions(input) {
|
|
137
|
+
const timeoutMs = typeof input.timeoutMs === 'number' && Number.isFinite(input.timeoutMs)
|
|
138
|
+
? Math.floor(input.timeoutMs)
|
|
139
|
+
: undefined;
|
|
140
|
+
const allowUnsafe = input.allowUnsafe === true;
|
|
141
|
+
return { timeoutMs, allowUnsafe };
|
|
142
|
+
}
|
|
143
|
+
function buildRuntimeExecSkill() {
|
|
144
|
+
return {
|
|
145
|
+
name: 'runtime.exec',
|
|
146
|
+
group: 'group:runtime',
|
|
147
|
+
aliases: ['shell_execute'],
|
|
148
|
+
description: 'Execute a shell command with timeout and safety checks.',
|
|
149
|
+
parameters: {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
command: { type: 'string' },
|
|
153
|
+
cwd: { type: 'string' },
|
|
154
|
+
timeoutMs: { type: 'number' },
|
|
155
|
+
allowUnsafe: { type: 'boolean' },
|
|
156
|
+
},
|
|
157
|
+
required: ['command'],
|
|
158
|
+
},
|
|
159
|
+
async execute(input) {
|
|
160
|
+
const commandValue = input.command;
|
|
161
|
+
if (typeof commandValue !== 'string' || commandValue.trim().length === 0) {
|
|
162
|
+
return { ok: false, output: 'command must be a non-empty string.' };
|
|
163
|
+
}
|
|
164
|
+
const cwd = typeof input.cwd === 'string' ? resolveWorkspacePath(input.cwd) : undefined;
|
|
165
|
+
const result = await executeShell(commandValue, cwd, resolveShellOptions(input));
|
|
166
|
+
await logToolCall('runtime.exec', input, result.output || '(no output)');
|
|
167
|
+
return {
|
|
168
|
+
ok: result.ok,
|
|
169
|
+
output: result.output,
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function buildRuntimePowerShellSkill() {
|
|
175
|
+
return {
|
|
176
|
+
name: 'runtime.powershell',
|
|
177
|
+
group: 'group:runtime',
|
|
178
|
+
description: 'Execute a script using Windows PowerShell with timeout and safety checks.',
|
|
179
|
+
parameters: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
script: { type: 'string' },
|
|
183
|
+
cwd: { type: 'string' },
|
|
184
|
+
timeoutMs: { type: 'number' },
|
|
185
|
+
allowUnsafe: { type: 'boolean' },
|
|
186
|
+
},
|
|
187
|
+
required: ['script'],
|
|
188
|
+
},
|
|
189
|
+
async execute(input) {
|
|
190
|
+
const scriptValue = input.script;
|
|
191
|
+
if (typeof scriptValue !== 'string' || scriptValue.trim().length === 0) {
|
|
192
|
+
return { ok: false, output: 'script must be a non-empty string.' };
|
|
193
|
+
}
|
|
194
|
+
const cwd = typeof input.cwd === 'string' ? resolveWorkspacePath(input.cwd) : undefined;
|
|
195
|
+
const command = `powershell -NoProfile -ExecutionPolicy Bypass -Command ${quoteShellArg(scriptValue)}`;
|
|
196
|
+
const result = await executeShell(command, cwd, resolveShellOptions(input));
|
|
197
|
+
await logToolCall('runtime.powershell', input, result.output || '(no output)');
|
|
198
|
+
return {
|
|
199
|
+
ok: result.ok,
|
|
200
|
+
output: result.output,
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function buildRuntimeProcessSkill() {
|
|
206
|
+
return {
|
|
207
|
+
name: 'runtime.process',
|
|
208
|
+
group: 'group:runtime',
|
|
209
|
+
description: 'Execute an executable with argument array under shell safety constraints.',
|
|
210
|
+
parameters: {
|
|
211
|
+
type: 'object',
|
|
212
|
+
properties: {
|
|
213
|
+
executable: { type: 'string' },
|
|
214
|
+
args: { type: 'array', items: { type: 'string' } },
|
|
215
|
+
cwd: { type: 'string' },
|
|
216
|
+
timeoutMs: { type: 'number' },
|
|
217
|
+
allowUnsafe: { type: 'boolean' },
|
|
218
|
+
},
|
|
219
|
+
required: ['executable'],
|
|
220
|
+
},
|
|
221
|
+
async execute(input) {
|
|
222
|
+
const executableValue = input.executable;
|
|
223
|
+
if (typeof executableValue !== 'string' || executableValue.trim().length === 0) {
|
|
224
|
+
return { ok: false, output: 'executable must be a non-empty string.' };
|
|
225
|
+
}
|
|
226
|
+
const argsValue = Array.isArray(input.args)
|
|
227
|
+
? input.args.filter((arg) => typeof arg === 'string')
|
|
228
|
+
: [];
|
|
229
|
+
const command = [quoteShellArg(executableValue), ...argsValue.map(quoteShellArg)].join(' ');
|
|
230
|
+
const cwd = typeof input.cwd === 'string' ? resolveWorkspacePath(input.cwd) : undefined;
|
|
231
|
+
const result = await executeShell(command, cwd, resolveShellOptions(input));
|
|
232
|
+
await logToolCall('runtime.process', input, result.output || '(no output)');
|
|
233
|
+
return {
|
|
234
|
+
ok: result.ok,
|
|
235
|
+
output: result.output,
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
export function createBuiltinSkills() {
|
|
241
|
+
return [
|
|
242
|
+
buildReadFileSkill(),
|
|
243
|
+
buildListFilesSkill(),
|
|
244
|
+
buildWriteFileSkill(),
|
|
245
|
+
buildApplyPatchSkill(),
|
|
246
|
+
buildRuntimeExecSkill(),
|
|
247
|
+
buildRuntimePowerShellSkill(),
|
|
248
|
+
buildRuntimeProcessSkill(),
|
|
249
|
+
];
|
|
250
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { logSystemCommand, scrubSensitiveText } from '../utils/logger.js';
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
6
|
+
const MAX_TIMEOUT_MS = 120_000;
|
|
7
|
+
const MAX_OUTPUT_LENGTH = 8_000;
|
|
8
|
+
const BLOCKED_COMMAND_PATTERNS = [
|
|
9
|
+
{ pattern: /\brm\s+-rf\s+\/\b/i, reason: 'destructive root delete' },
|
|
10
|
+
{ pattern: /\brm\s+-rf\s+\*/i, reason: 'destructive wildcard delete' },
|
|
11
|
+
{ pattern: /\bdel\s+\/[A-Za-z]*\s+\/[A-Za-z]*\s+[A-Za-z]:\\/i, reason: 'destructive windows delete' },
|
|
12
|
+
{ pattern: /\bformat\s+[A-Za-z]:/i, reason: 'disk format command' },
|
|
13
|
+
{ pattern: /\bshutdown\b/i, reason: 'system shutdown command' },
|
|
14
|
+
{ pattern: /\breboot\b/i, reason: 'system reboot command' },
|
|
15
|
+
{ pattern: /\bpoweroff\b/i, reason: 'system poweroff command' },
|
|
16
|
+
];
|
|
17
|
+
function truncateOutput(output) {
|
|
18
|
+
if (output.length <= MAX_OUTPUT_LENGTH) {
|
|
19
|
+
return output;
|
|
20
|
+
}
|
|
21
|
+
return `${output.slice(0, MAX_OUTPUT_LENGTH)}\n...[truncated]`;
|
|
22
|
+
}
|
|
23
|
+
function resolveTimeout(timeoutMs) {
|
|
24
|
+
if (!Number.isFinite(timeoutMs)) {
|
|
25
|
+
return DEFAULT_TIMEOUT_MS;
|
|
26
|
+
}
|
|
27
|
+
const parsed = Math.floor(Number(timeoutMs));
|
|
28
|
+
if (parsed < 1) {
|
|
29
|
+
return DEFAULT_TIMEOUT_MS;
|
|
30
|
+
}
|
|
31
|
+
return Math.min(MAX_TIMEOUT_MS, parsed);
|
|
32
|
+
}
|
|
33
|
+
function detectBlockedCommand(command) {
|
|
34
|
+
for (const entry of BLOCKED_COMMAND_PATTERNS) {
|
|
35
|
+
if (entry.pattern.test(command)) {
|
|
36
|
+
return entry.reason;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
export async function executeShell(command, cwd, options = {}) {
|
|
42
|
+
const normalizedCommand = command.trim();
|
|
43
|
+
if (normalizedCommand.length === 0) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
output: 'Command must be a non-empty string.',
|
|
47
|
+
exitCode: 1,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const blockedReason = options.allowUnsafe ? null : detectBlockedCommand(normalizedCommand);
|
|
51
|
+
if (blockedReason) {
|
|
52
|
+
const blockedOutput = `Blocked unsafe command (${blockedReason}). Set allowUnsafe=true to override.`;
|
|
53
|
+
await logSystemCommand(normalizedCommand, blockedOutput, 126);
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
output: blockedOutput,
|
|
57
|
+
exitCode: 126,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const timeout = resolveTimeout(options.timeoutMs);
|
|
61
|
+
try {
|
|
62
|
+
const { stdout, stderr } = await execAsync(normalizedCommand, {
|
|
63
|
+
cwd,
|
|
64
|
+
timeout,
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
maxBuffer: 1024 * 1024,
|
|
67
|
+
});
|
|
68
|
+
const mergedOutput = truncateOutput(scrubSensitiveText(`${stdout}${stderr}`.trim()));
|
|
69
|
+
await logSystemCommand(normalizedCommand, mergedOutput || '(no output)', 0);
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
output: mergedOutput,
|
|
73
|
+
exitCode: 0,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const err = error;
|
|
78
|
+
const mergedOutput = truncateOutput(scrubSensitiveText(`${err.stdout ?? ''}${err.stderr ?? ''}${err.message ?? ''}`.trim()));
|
|
79
|
+
const exitCode = typeof err.code === 'number' ? err.code : 1;
|
|
80
|
+
await logSystemCommand(normalizedCommand, mergedOutput || '(no output)', exitCode);
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
output: mergedOutput,
|
|
84
|
+
exitCode,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const PERSONA_DOCUMENT_KEYS = ['soul', 'identity', 'user'];
|
|
2
|
+
export class PersonaValidationError extends Error {
|
|
3
|
+
hints;
|
|
4
|
+
constructor(hints) {
|
|
5
|
+
super('Persona state validation failed.');
|
|
6
|
+
this.name = 'PersonaValidationError';
|
|
7
|
+
this.hints = hints;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class PersonaConflictError extends Error {
|
|
11
|
+
latestRevision;
|
|
12
|
+
hints;
|
|
13
|
+
constructor(latestRevision) {
|
|
14
|
+
super('Persona state is stale. Reload and retry.');
|
|
15
|
+
this.name = 'PersonaConflictError';
|
|
16
|
+
this.latestRevision = latestRevision;
|
|
17
|
+
this.hints = [
|
|
18
|
+
'Reload persona state before saving again.',
|
|
19
|
+
'Re-apply your edits on top of the latest revision.',
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|