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,110 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { logThought } from '../utils/logger.js';
|
|
3
|
+
const MAX_HISTORY = 200;
|
|
4
|
+
/**
|
|
5
|
+
* Tracks outbound message delivery outcomes for reliability telemetry.
|
|
6
|
+
*
|
|
7
|
+
* Maintains an in-memory ring buffer of delivery records. Each send operation
|
|
8
|
+
* is recorded with its attempts, timing, and final outcome. Provides summary
|
|
9
|
+
* metrics for dashboards and operational visibility.
|
|
10
|
+
*
|
|
11
|
+
* Not thread-safe — designed for single-process Node.js usage.
|
|
12
|
+
*/
|
|
13
|
+
export class DeliveryTracker {
|
|
14
|
+
#records = [];
|
|
15
|
+
#reconciliations = [];
|
|
16
|
+
/** Create a new delivery record and return its ID. */
|
|
17
|
+
createRecord(platform, chatId) {
|
|
18
|
+
const id = randomUUID();
|
|
19
|
+
const record = {
|
|
20
|
+
id,
|
|
21
|
+
platform,
|
|
22
|
+
chatId,
|
|
23
|
+
textPayload: '', // Added missing property
|
|
24
|
+
state: 'queued',
|
|
25
|
+
attempts: [],
|
|
26
|
+
createdAt: new Date().toISOString(),
|
|
27
|
+
};
|
|
28
|
+
this.#records.push(record);
|
|
29
|
+
// Trim ring buffer
|
|
30
|
+
if (this.#records.length > MAX_HISTORY) {
|
|
31
|
+
this.#records.splice(0, this.#records.length - MAX_HISTORY);
|
|
32
|
+
}
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
35
|
+
/** Record the start of a send attempt. */
|
|
36
|
+
recordAttemptStart(recordId) {
|
|
37
|
+
const record = this.#findRecord(recordId);
|
|
38
|
+
if (!record)
|
|
39
|
+
return;
|
|
40
|
+
const attempt = {
|
|
41
|
+
attemptNumber: record.attempts.length + 1,
|
|
42
|
+
startedAt: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
record.attempts.push(attempt);
|
|
45
|
+
record.state = 'dispatching';
|
|
46
|
+
}
|
|
47
|
+
/** Mark the latest attempt as successful. */
|
|
48
|
+
recordSuccess(recordId) {
|
|
49
|
+
const record = this.#findRecord(recordId);
|
|
50
|
+
if (!record)
|
|
51
|
+
return;
|
|
52
|
+
const last = record.attempts[record.attempts.length - 1];
|
|
53
|
+
if (last) {
|
|
54
|
+
last.completedAt = new Date().toISOString();
|
|
55
|
+
last.durationMs = new Date(last.completedAt).getTime() - new Date(last.startedAt).getTime();
|
|
56
|
+
}
|
|
57
|
+
record.state = 'sent';
|
|
58
|
+
record.resolvedAt = new Date().toISOString();
|
|
59
|
+
}
|
|
60
|
+
/** Mark the latest attempt as failed. */
|
|
61
|
+
recordFailure(recordId, error) {
|
|
62
|
+
const record = this.#findRecord(recordId);
|
|
63
|
+
if (!record)
|
|
64
|
+
return;
|
|
65
|
+
const last = record.attempts[record.attempts.length - 1];
|
|
66
|
+
if (last) {
|
|
67
|
+
last.completedAt = new Date().toISOString();
|
|
68
|
+
last.error = error;
|
|
69
|
+
last.durationMs = new Date(last.completedAt).getTime() - new Date(last.startedAt).getTime();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Mark a record as ultimately failed (all retries exhausted). */
|
|
73
|
+
markFailed(recordId) {
|
|
74
|
+
const record = this.#findRecord(recordId);
|
|
75
|
+
if (!record)
|
|
76
|
+
return;
|
|
77
|
+
record.state = 'failed';
|
|
78
|
+
record.resolvedAt = new Date().toISOString();
|
|
79
|
+
void logThought(`[DeliveryTracker] Message ${recordId} FAILED after ${record.attempts.length} attempt(s) to ${record.platform}:${record.chatId}.`);
|
|
80
|
+
}
|
|
81
|
+
/** Record an external callback reconciliation event. */
|
|
82
|
+
recordReconciliation(payload) {
|
|
83
|
+
this.#reconciliations.push(payload);
|
|
84
|
+
if (this.#reconciliations.length > MAX_HISTORY) {
|
|
85
|
+
this.#reconciliations.splice(0, this.#reconciliations.length - MAX_HISTORY);
|
|
86
|
+
}
|
|
87
|
+
void logThought(`[DeliveryTracker] Callback reconciled — task: ${payload.taskId}, status: ${payload.status}.`);
|
|
88
|
+
}
|
|
89
|
+
/** Compute reliability metrics from the current record history. */
|
|
90
|
+
getMetrics(limit = 50) {
|
|
91
|
+
const resolved = this.#records.filter((r) => r.state === 'sent' || r.state === 'failed');
|
|
92
|
+
const totalSent = resolved.filter((r) => r.state === 'sent').length;
|
|
93
|
+
const totalFailed = resolved.filter((r) => r.state === 'failed').length;
|
|
94
|
+
const totalRetries = resolved.reduce((sum, r) => sum + Math.max(0, r.attempts.length - 1), 0);
|
|
95
|
+
const averageAttempts = resolved.length > 0
|
|
96
|
+
? resolved.reduce((sum, r) => sum + r.attempts.length, 0) / resolved.length
|
|
97
|
+
: 0;
|
|
98
|
+
return {
|
|
99
|
+
totalSent,
|
|
100
|
+
totalFailed,
|
|
101
|
+
totalRetries,
|
|
102
|
+
averageAttempts: Math.round(averageAttempts * 100) / 100,
|
|
103
|
+
recentRecords: this.#records.slice(-limit),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
107
|
+
#findRecord(id) {
|
|
108
|
+
return this.#records.find((r) => r.id === id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
5
|
+
const CODE_LENGTH = 8;
|
|
6
|
+
const DEFAULT_CODE_TTL_MS = 60 * 60 * 1000;
|
|
7
|
+
const DEFAULT_MAX_PENDING_PER_CHANNEL = 3;
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return typeof value === 'object' && value !== null;
|
|
10
|
+
}
|
|
11
|
+
function isIsoTimestamp(value) {
|
|
12
|
+
return Number.isFinite(Date.parse(value));
|
|
13
|
+
}
|
|
14
|
+
function isPairingRequestRecord(value) {
|
|
15
|
+
if (!isRecord(value)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const senderId = value['senderId'];
|
|
19
|
+
const code = value['code'];
|
|
20
|
+
const requestedAt = value['requestedAt'];
|
|
21
|
+
const expiresAt = value['expiresAt'];
|
|
22
|
+
return (typeof senderId === 'string' &&
|
|
23
|
+
senderId.length > 0 &&
|
|
24
|
+
typeof code === 'string' &&
|
|
25
|
+
/^[A-Z2-9]{8}$/.test(code) &&
|
|
26
|
+
typeof requestedAt === 'string' &&
|
|
27
|
+
isIsoTimestamp(requestedAt) &&
|
|
28
|
+
typeof expiresAt === 'string' &&
|
|
29
|
+
isIsoTimestamp(expiresAt));
|
|
30
|
+
}
|
|
31
|
+
function assertPairingPendingStore(value, filePath) {
|
|
32
|
+
if (!isRecord(value) || !Array.isArray(value['requests'])) {
|
|
33
|
+
throw new Error(`Invalid pairing pending store format at '${filePath}'.`);
|
|
34
|
+
}
|
|
35
|
+
const requests = value['requests'];
|
|
36
|
+
if (!requests.every((entry) => isPairingRequestRecord(entry))) {
|
|
37
|
+
throw new Error(`Invalid pairing request entries at '${filePath}'.`);
|
|
38
|
+
}
|
|
39
|
+
return { requests };
|
|
40
|
+
}
|
|
41
|
+
function assertPairingAllowStore(value, filePath) {
|
|
42
|
+
if (!isRecord(value) || !Array.isArray(value['senderIds'])) {
|
|
43
|
+
throw new Error(`Invalid pairing allow store format at '${filePath}'.`);
|
|
44
|
+
}
|
|
45
|
+
const senderIds = value['senderIds'];
|
|
46
|
+
if (!senderIds.every((entry) => typeof entry === 'string' && entry.length > 0)) {
|
|
47
|
+
throw new Error(`Invalid allowlist sender IDs at '${filePath}'.`);
|
|
48
|
+
}
|
|
49
|
+
return { senderIds };
|
|
50
|
+
}
|
|
51
|
+
export function isPairingChannel(value) {
|
|
52
|
+
return value === 'telegram' || value === 'whatsapp';
|
|
53
|
+
}
|
|
54
|
+
export function normalizePairingSenderId(channel, senderId) {
|
|
55
|
+
const trimmed = senderId.trim();
|
|
56
|
+
if (channel === 'telegram') {
|
|
57
|
+
return trimmed;
|
|
58
|
+
}
|
|
59
|
+
const localPart = trimmed.split('@')[0] ?? trimmed;
|
|
60
|
+
return localPart.replace(/[\s\+\-\(\)]/g, '');
|
|
61
|
+
}
|
|
62
|
+
export class DmPairingService {
|
|
63
|
+
#credentialsDir;
|
|
64
|
+
#codeTtlMs;
|
|
65
|
+
#maxPendingPerChannel;
|
|
66
|
+
constructor(options = {}) {
|
|
67
|
+
this.#credentialsDir = options.credentialsDir
|
|
68
|
+
? path.resolve(options.credentialsDir)
|
|
69
|
+
: path.resolve('memory', 'credentials');
|
|
70
|
+
this.#codeTtlMs =
|
|
71
|
+
Number.isFinite(options.codeTtlMs) && (options.codeTtlMs ?? 0) > 0
|
|
72
|
+
? Number(options.codeTtlMs)
|
|
73
|
+
: DEFAULT_CODE_TTL_MS;
|
|
74
|
+
this.#maxPendingPerChannel =
|
|
75
|
+
Number.isFinite(options.maxPendingPerChannel) && (options.maxPendingPerChannel ?? 0) > 0
|
|
76
|
+
? Number(options.maxPendingPerChannel)
|
|
77
|
+
: DEFAULT_MAX_PENDING_PER_CHANNEL;
|
|
78
|
+
}
|
|
79
|
+
listPending(channel, nowMs = Date.now()) {
|
|
80
|
+
const pending = this.#readPendingStore(channel);
|
|
81
|
+
const { activeRequests, changed } = this.#pruneExpired(pending.requests, nowMs);
|
|
82
|
+
if (changed) {
|
|
83
|
+
this.#writePendingStore(channel, activeRequests);
|
|
84
|
+
}
|
|
85
|
+
return [...activeRequests].sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
|
|
86
|
+
}
|
|
87
|
+
listApproved(channel) {
|
|
88
|
+
const store = this.#readAllowStore(channel);
|
|
89
|
+
return [...store.senderIds].sort();
|
|
90
|
+
}
|
|
91
|
+
isApproved(channel, senderId) {
|
|
92
|
+
const normalized = normalizePairingSenderId(channel, senderId);
|
|
93
|
+
if (!normalized) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const store = this.#readAllowStore(channel);
|
|
97
|
+
return store.senderIds.includes(normalized);
|
|
98
|
+
}
|
|
99
|
+
seedAllowFrom(channel, senderIds) {
|
|
100
|
+
const normalized = senderIds
|
|
101
|
+
.map((senderId) => normalizePairingSenderId(channel, senderId))
|
|
102
|
+
.filter((senderId) => senderId.length > 0);
|
|
103
|
+
if (normalized.length === 0) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const store = this.#readAllowStore(channel);
|
|
107
|
+
const merged = [...new Set([...store.senderIds, ...normalized])].sort();
|
|
108
|
+
this.#writeAllowStore(channel, merged);
|
|
109
|
+
}
|
|
110
|
+
requestPairing(channel, senderId, nowMs = Date.now()) {
|
|
111
|
+
const normalizedSenderId = normalizePairingSenderId(channel, senderId);
|
|
112
|
+
if (!normalizedSenderId) {
|
|
113
|
+
return { status: 'limit_reached' };
|
|
114
|
+
}
|
|
115
|
+
const pending = this.#readPendingStore(channel);
|
|
116
|
+
const { activeRequests } = this.#pruneExpired(pending.requests, nowMs);
|
|
117
|
+
const existing = activeRequests.find((request) => request.senderId === normalizedSenderId);
|
|
118
|
+
if (existing) {
|
|
119
|
+
if (activeRequests.length !== pending.requests.length) {
|
|
120
|
+
this.#writePendingStore(channel, activeRequests);
|
|
121
|
+
}
|
|
122
|
+
return { status: 'existing', request: existing };
|
|
123
|
+
}
|
|
124
|
+
if (activeRequests.length >= this.#maxPendingPerChannel) {
|
|
125
|
+
if (activeRequests.length !== pending.requests.length) {
|
|
126
|
+
this.#writePendingStore(channel, activeRequests);
|
|
127
|
+
}
|
|
128
|
+
return { status: 'limit_reached' };
|
|
129
|
+
}
|
|
130
|
+
const request = {
|
|
131
|
+
senderId: normalizedSenderId,
|
|
132
|
+
code: this.#generateUniqueCode(activeRequests),
|
|
133
|
+
requestedAt: new Date(nowMs).toISOString(),
|
|
134
|
+
expiresAt: new Date(nowMs + this.#codeTtlMs).toISOString(),
|
|
135
|
+
};
|
|
136
|
+
this.#writePendingStore(channel, [...activeRequests, request]);
|
|
137
|
+
return { status: 'created', request };
|
|
138
|
+
}
|
|
139
|
+
approve(channel, code, nowMs = Date.now()) {
|
|
140
|
+
const normalizedCode = code.trim().toUpperCase();
|
|
141
|
+
if (!/^[A-Z2-9]{8}$/.test(normalizedCode)) {
|
|
142
|
+
return { status: 'not_found' };
|
|
143
|
+
}
|
|
144
|
+
const pending = this.#readPendingStore(channel);
|
|
145
|
+
const request = pending.requests.find((entry) => entry.code === normalizedCode);
|
|
146
|
+
const { activeRequests } = this.#pruneExpired(pending.requests, nowMs);
|
|
147
|
+
if (!request) {
|
|
148
|
+
if (activeRequests.length !== pending.requests.length) {
|
|
149
|
+
this.#writePendingStore(channel, activeRequests);
|
|
150
|
+
}
|
|
151
|
+
return { status: 'not_found' };
|
|
152
|
+
}
|
|
153
|
+
if (Date.parse(request.expiresAt) <= nowMs) {
|
|
154
|
+
const withoutExpired = activeRequests.filter((entry) => entry.code !== normalizedCode);
|
|
155
|
+
this.#writePendingStore(channel, withoutExpired);
|
|
156
|
+
return { status: 'expired' };
|
|
157
|
+
}
|
|
158
|
+
const remaining = activeRequests.filter((entry) => entry.code !== normalizedCode);
|
|
159
|
+
this.#writePendingStore(channel, remaining);
|
|
160
|
+
this.seedAllowFrom(channel, [request.senderId]);
|
|
161
|
+
return { status: 'approved', senderId: request.senderId };
|
|
162
|
+
}
|
|
163
|
+
#generateUniqueCode(existingRequests) {
|
|
164
|
+
const existingCodes = new Set(existingRequests.map((request) => request.code));
|
|
165
|
+
for (let attempts = 0; attempts < 128; attempts++) {
|
|
166
|
+
const code = this.#generateCode();
|
|
167
|
+
if (!existingCodes.has(code)) {
|
|
168
|
+
return code;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new Error('Unable to generate a unique pairing code after multiple attempts.');
|
|
172
|
+
}
|
|
173
|
+
#generateCode() {
|
|
174
|
+
const bytes = randomBytes(CODE_LENGTH);
|
|
175
|
+
let code = '';
|
|
176
|
+
for (let index = 0; index < CODE_LENGTH; index++) {
|
|
177
|
+
const alphabetIndex = bytes[index] % CODE_ALPHABET.length;
|
|
178
|
+
code += CODE_ALPHABET[alphabetIndex];
|
|
179
|
+
}
|
|
180
|
+
return code;
|
|
181
|
+
}
|
|
182
|
+
#pruneExpired(requests, nowMs) {
|
|
183
|
+
const activeRequests = requests.filter((request) => Date.parse(request.expiresAt) > nowMs);
|
|
184
|
+
return { activeRequests, changed: activeRequests.length !== requests.length };
|
|
185
|
+
}
|
|
186
|
+
#pendingStorePath(channel) {
|
|
187
|
+
return path.join(this.#credentialsDir, `${channel}-pairing.json`);
|
|
188
|
+
}
|
|
189
|
+
#allowStorePath(channel) {
|
|
190
|
+
return path.join(this.#credentialsDir, `${channel}-allowFrom.json`);
|
|
191
|
+
}
|
|
192
|
+
#readPendingStore(channel) {
|
|
193
|
+
const filePath = this.#pendingStorePath(channel);
|
|
194
|
+
if (!fs.existsSync(filePath)) {
|
|
195
|
+
return { requests: [] };
|
|
196
|
+
}
|
|
197
|
+
const raw = this.#readJson(filePath);
|
|
198
|
+
return assertPairingPendingStore(raw, filePath);
|
|
199
|
+
}
|
|
200
|
+
#readAllowStore(channel) {
|
|
201
|
+
const filePath = this.#allowStorePath(channel);
|
|
202
|
+
if (!fs.existsSync(filePath)) {
|
|
203
|
+
return { senderIds: [] };
|
|
204
|
+
}
|
|
205
|
+
const raw = this.#readJson(filePath);
|
|
206
|
+
return assertPairingAllowStore(raw, filePath);
|
|
207
|
+
}
|
|
208
|
+
#writePendingStore(channel, requests) {
|
|
209
|
+
this.#writeJson(this.#pendingStorePath(channel), { requests });
|
|
210
|
+
}
|
|
211
|
+
#writeAllowStore(channel, senderIds) {
|
|
212
|
+
this.#writeJson(this.#allowStorePath(channel), {
|
|
213
|
+
senderIds: [...new Set(senderIds)].sort(),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
#readJson(filePath) {
|
|
217
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
218
|
+
return JSON.parse(raw);
|
|
219
|
+
}
|
|
220
|
+
#writeJson(filePath, payload) {
|
|
221
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
222
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
223
|
+
const content = `${JSON.stringify(payload, null, 2)}\n`;
|
|
224
|
+
try {
|
|
225
|
+
fs.writeFileSync(tempPath, content, 'utf8');
|
|
226
|
+
fs.renameSync(tempPath, filePath);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
if (fs.existsSync(tempPath)) {
|
|
230
|
+
fs.unlinkSync(tempPath);
|
|
231
|
+
}
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
let pairingServiceSingleton = null;
|
|
237
|
+
export function getDmPairingService() {
|
|
238
|
+
if (!pairingServiceSingleton) {
|
|
239
|
+
pairingServiceSingleton = new DmPairingService();
|
|
240
|
+
}
|
|
241
|
+
return pairingServiceSingleton;
|
|
242
|
+
}
|
|
243
|
+
export function resetDmPairingServiceForTests() {
|
|
244
|
+
pairingServiceSingleton = null;
|
|
245
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { getSecretVaultService } from './secret-vault.js';
|
|
2
|
+
import { getConfigValue } from '../config/config-loader.js';
|
|
3
|
+
const DEFAULT_OPENAI_URL = 'https://api.openai.com/v1/embeddings';
|
|
4
|
+
const DEFAULT_OPENAI_MODEL = 'text-embedding-3-small';
|
|
5
|
+
const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
|
|
6
|
+
const DEFAULT_OLLAMA_MODEL = 'mxbai-embed-large';
|
|
7
|
+
function normalizeEmbeddingLength(embedding, expectedDimensions) {
|
|
8
|
+
if (embedding.length === expectedDimensions) {
|
|
9
|
+
return embedding;
|
|
10
|
+
}
|
|
11
|
+
if (embedding.length > expectedDimensions) {
|
|
12
|
+
return embedding.slice(0, expectedDimensions);
|
|
13
|
+
}
|
|
14
|
+
return [...embedding, ...new Array(expectedDimensions - embedding.length).fill(0)];
|
|
15
|
+
}
|
|
16
|
+
export class EmbeddingService {
|
|
17
|
+
expectedDimensions;
|
|
18
|
+
constructor() {
|
|
19
|
+
const configuredDimensions = Number(getConfigValue('MEMORY_EMBEDDING_DIM') ?? '1536');
|
|
20
|
+
this.expectedDimensions = Number.isFinite(configuredDimensions) && configuredDimensions > 0
|
|
21
|
+
? configuredDimensions
|
|
22
|
+
: 1536;
|
|
23
|
+
}
|
|
24
|
+
async embedText(input) {
|
|
25
|
+
const normalizedInput = input.trim();
|
|
26
|
+
if (!normalizedInput) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const providers = this.getProviderOrder();
|
|
30
|
+
for (const provider of providers) {
|
|
31
|
+
try {
|
|
32
|
+
const embedding = provider === 'ollama'
|
|
33
|
+
? await this.embedWithOllama(normalizedInput)
|
|
34
|
+
: await this.embedWithOpenAI(normalizedInput);
|
|
35
|
+
if (embedding.length > 0) {
|
|
36
|
+
return normalizeEmbeddingLength(embedding, this.expectedDimensions);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
+
console.warn(`[EmbeddingService] Provider '${provider}' failed: ${message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
getProviderOrder() {
|
|
47
|
+
const configured = (getConfigValue('EMBEDDING_PROVIDER') ?? '').toLowerCase().trim();
|
|
48
|
+
if (configured === 'ollama') {
|
|
49
|
+
return ['ollama', 'openai'];
|
|
50
|
+
}
|
|
51
|
+
if (configured === 'openai') {
|
|
52
|
+
return ['openai', 'ollama'];
|
|
53
|
+
}
|
|
54
|
+
return ['openai', 'ollama'];
|
|
55
|
+
}
|
|
56
|
+
async embedWithOpenAI(input) {
|
|
57
|
+
const secretVault = getSecretVaultService();
|
|
58
|
+
const apiKey = secretVault.readSecret('EMBEDDING_API_KEY') ?? secretVault.readSecret('OPENAI_API_KEY') ?? '';
|
|
59
|
+
if (!apiKey) {
|
|
60
|
+
throw new Error('Missing EMBEDDING_API_KEY or OPENAI_API_KEY.');
|
|
61
|
+
}
|
|
62
|
+
const endpoint = getConfigValue('EMBEDDING_API_URL') ?? DEFAULT_OPENAI_URL;
|
|
63
|
+
const model = getConfigValue('EMBEDDING_MODEL') ?? DEFAULT_OPENAI_MODEL;
|
|
64
|
+
const response = await fetch(endpoint, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
Authorization: `Bearer ${apiKey}`,
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
model,
|
|
72
|
+
input,
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`OpenAI embeddings request failed (${response.status}).`);
|
|
77
|
+
}
|
|
78
|
+
const data = await response.json();
|
|
79
|
+
const embedding = data.data?.[0]?.embedding;
|
|
80
|
+
if (!Array.isArray(embedding)) {
|
|
81
|
+
throw new Error('OpenAI embeddings response did not contain an embedding array.');
|
|
82
|
+
}
|
|
83
|
+
return embedding;
|
|
84
|
+
}
|
|
85
|
+
async embedWithOllama(input) {
|
|
86
|
+
const baseUrl = getConfigValue('OLLAMA_BASE_URL') ?? DEFAULT_OLLAMA_URL;
|
|
87
|
+
const model = getConfigValue('OLLAMA_EMBEDDING_MODEL') ?? DEFAULT_OLLAMA_MODEL;
|
|
88
|
+
const endpoint = `${baseUrl.replace(/\/$/, '')}/api/embeddings`;
|
|
89
|
+
const response = await fetch(endpoint, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
model,
|
|
96
|
+
prompt: input,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
throw new Error(`Ollama embeddings request failed (${response.status}).`);
|
|
101
|
+
}
|
|
102
|
+
const data = await response.json();
|
|
103
|
+
if (!Array.isArray(data.embedding)) {
|
|
104
|
+
throw new Error('Ollama embeddings response did not contain an embedding array.');
|
|
105
|
+
}
|
|
106
|
+
return data.embedding;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function chunkText(content, chunkSize = 900, overlap = 120) {
|
|
110
|
+
const source = content.replace(/\s+/g, ' ').trim();
|
|
111
|
+
if (!source) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let index = 0;
|
|
116
|
+
while (index < source.length) {
|
|
117
|
+
const end = Math.min(index + chunkSize, source.length);
|
|
118
|
+
chunks.push(source.slice(index, end));
|
|
119
|
+
if (end >= source.length) {
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
index = Math.max(end - overlap, index + 1);
|
|
123
|
+
}
|
|
124
|
+
return chunks;
|
|
125
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { watch } from 'chokidar';
|
|
2
|
+
import { logThought } from '../utils/logger.js';
|
|
3
|
+
const DEFAULT_EXCLUDE = ['**/node_modules/**', '**/.git/**', '**/dist/**'];
|
|
4
|
+
/**
|
|
5
|
+
* Monitors specific local directories for filesystem changes and notifies
|
|
6
|
+
* registered listeners.
|
|
7
|
+
*
|
|
8
|
+
* Uses `chokidar` for cross-platform, efficient file watching with debouncing
|
|
9
|
+
* and glob-based filtering.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* ```ts
|
|
13
|
+
* const watcher = new FileWatcherService();
|
|
14
|
+
* watcher.addTarget({
|
|
15
|
+
* id: 'workspace',
|
|
16
|
+
* directory: '/path/to/project',
|
|
17
|
+
* exclude: ['node_modules/**'],
|
|
18
|
+
* });
|
|
19
|
+
* watcher.onEvent((event) => console.log(event));
|
|
20
|
+
* watcher.startAll();
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export class FileWatcherService {
|
|
24
|
+
#targets = new Map();
|
|
25
|
+
#watchers = new Map();
|
|
26
|
+
#listeners = new Set();
|
|
27
|
+
/** Register a directory to watch. Does NOT start watching until `start()` is called. */
|
|
28
|
+
addTarget(target) {
|
|
29
|
+
if (this.#targets.has(target.id)) {
|
|
30
|
+
throw new Error(`[FileWatcher] Target '${target.id}' is already registered.`);
|
|
31
|
+
}
|
|
32
|
+
this.#targets.set(target.id, target);
|
|
33
|
+
}
|
|
34
|
+
/** Remove a target and close its watcher if running. */
|
|
35
|
+
async removeTarget(targetId) {
|
|
36
|
+
const existing = this.#watchers.get(targetId);
|
|
37
|
+
if (existing) {
|
|
38
|
+
await existing.close();
|
|
39
|
+
this.#watchers.delete(targetId);
|
|
40
|
+
}
|
|
41
|
+
return this.#targets.delete(targetId);
|
|
42
|
+
}
|
|
43
|
+
/** Subscribe to all file events. Returns an unsubscribe function. */
|
|
44
|
+
onEvent(listener) {
|
|
45
|
+
this.#listeners.add(listener);
|
|
46
|
+
return () => {
|
|
47
|
+
this.#listeners.delete(listener);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Start watching a specific target by ID. */
|
|
51
|
+
async start(targetId) {
|
|
52
|
+
const target = this.#targets.get(targetId);
|
|
53
|
+
if (!target) {
|
|
54
|
+
throw new Error(`[FileWatcher] Target '${targetId}' is not registered.`);
|
|
55
|
+
}
|
|
56
|
+
if (this.#watchers.has(targetId))
|
|
57
|
+
return; // Already watching
|
|
58
|
+
const ignored = [...DEFAULT_EXCLUDE, ...(target.exclude ?? [])];
|
|
59
|
+
const watcher = watch(target.directory, {
|
|
60
|
+
ignored,
|
|
61
|
+
persistent: true,
|
|
62
|
+
ignoreInitial: true,
|
|
63
|
+
awaitWriteFinish: {
|
|
64
|
+
stabilityThreshold: 300,
|
|
65
|
+
pollInterval: 100,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const eventTypes = ['add', 'change', 'unlink', 'addDir', 'unlinkDir'];
|
|
69
|
+
for (const eventType of eventTypes) {
|
|
70
|
+
watcher.on(eventType, (filePath) => {
|
|
71
|
+
void this.#handleEvent(eventType, filePath);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
watcher.on('error', (err) => {
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
76
|
+
console.error(`[FileWatcher] Error on target '${targetId}':`, message);
|
|
77
|
+
});
|
|
78
|
+
this.#watchers.set(targetId, watcher);
|
|
79
|
+
await logThought(`[FileWatcher] Started watching '${targetId}' at ${target.directory}`);
|
|
80
|
+
}
|
|
81
|
+
/** Start watching all registered targets. */
|
|
82
|
+
async startAll() {
|
|
83
|
+
for (const targetId of this.#targets.keys()) {
|
|
84
|
+
await this.start(targetId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Stop watching a specific target. */
|
|
88
|
+
async stop(targetId) {
|
|
89
|
+
const watcher = this.#watchers.get(targetId);
|
|
90
|
+
if (!watcher)
|
|
91
|
+
return;
|
|
92
|
+
await watcher.close();
|
|
93
|
+
this.#watchers.delete(targetId);
|
|
94
|
+
await logThought(`[FileWatcher] Stopped watching '${targetId}'.`);
|
|
95
|
+
}
|
|
96
|
+
/** Stop all watchers gracefully. */
|
|
97
|
+
async stopAll() {
|
|
98
|
+
for (const [targetId, watcher] of this.#watchers) {
|
|
99
|
+
await watcher.close();
|
|
100
|
+
await logThought(`[FileWatcher] Stopped watching '${targetId}'.`);
|
|
101
|
+
}
|
|
102
|
+
this.#watchers.clear();
|
|
103
|
+
}
|
|
104
|
+
/** Return a list of currently active watch target IDs. */
|
|
105
|
+
activeTargets() {
|
|
106
|
+
return [...this.#watchers.keys()];
|
|
107
|
+
}
|
|
108
|
+
// ── Private Helpers ────────────────────────────────────────────────────────
|
|
109
|
+
async #handleEvent(type, filePath) {
|
|
110
|
+
const event = {
|
|
111
|
+
type,
|
|
112
|
+
path: filePath,
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
};
|
|
115
|
+
await logThought(`[FileWatcher] ${type.toUpperCase()} detected: ${filePath}`);
|
|
116
|
+
for (const listener of this.#listeners) {
|
|
117
|
+
try {
|
|
118
|
+
await listener(event);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
console.error('[FileWatcher] Listener threw an error:', err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const DEFAULT_DEBOUNCE_MS = 1500;
|
|
2
|
+
export class InboundDebounceService {
|
|
3
|
+
#enabled;
|
|
4
|
+
#debounceMs;
|
|
5
|
+
#now;
|
|
6
|
+
#pending = new Map();
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.#enabled = options.enabled ?? true;
|
|
9
|
+
this.#debounceMs = Math.max(0, Math.floor(options.debounceMs ?? DEFAULT_DEBOUNCE_MS));
|
|
10
|
+
this.#now = options.now ?? (() => Date.now());
|
|
11
|
+
}
|
|
12
|
+
get enabled() {
|
|
13
|
+
return this.#enabled;
|
|
14
|
+
}
|
|
15
|
+
get debounceMs() {
|
|
16
|
+
return this.#debounceMs;
|
|
17
|
+
}
|
|
18
|
+
debounce(message) {
|
|
19
|
+
if (!this.#enabled || this.#debounceMs <= 0) {
|
|
20
|
+
return Promise.resolve(message);
|
|
21
|
+
}
|
|
22
|
+
const key = this.#buildKey(message);
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const existing = this.#pending.get(key);
|
|
25
|
+
if (existing) {
|
|
26
|
+
clearTimeout(existing.timer);
|
|
27
|
+
if (message.text) {
|
|
28
|
+
existing.texts.push(message.text);
|
|
29
|
+
}
|
|
30
|
+
existing.resolve = resolve;
|
|
31
|
+
existing.timer = this.#scheduleFlush(key);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const texts = message.text ? [message.text] : [];
|
|
35
|
+
const pending = {
|
|
36
|
+
message,
|
|
37
|
+
timer: this.#scheduleFlush(key),
|
|
38
|
+
resolve,
|
|
39
|
+
texts,
|
|
40
|
+
};
|
|
41
|
+
this.#pending.set(key, pending);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
flushAll() {
|
|
46
|
+
const messages = [];
|
|
47
|
+
for (const [key, pending] of this.#pending) {
|
|
48
|
+
clearTimeout(pending.timer);
|
|
49
|
+
const merged = this.#mergeMessages(pending);
|
|
50
|
+
messages.push(merged);
|
|
51
|
+
this.#pending.delete(key);
|
|
52
|
+
}
|
|
53
|
+
return messages;
|
|
54
|
+
}
|
|
55
|
+
clear() {
|
|
56
|
+
for (const pending of this.#pending.values()) {
|
|
57
|
+
clearTimeout(pending.timer);
|
|
58
|
+
}
|
|
59
|
+
this.#pending.clear();
|
|
60
|
+
}
|
|
61
|
+
getPendingCount() {
|
|
62
|
+
return this.#pending.size;
|
|
63
|
+
}
|
|
64
|
+
#buildKey(message) {
|
|
65
|
+
return `${message.platform}:${message.chatId}`;
|
|
66
|
+
}
|
|
67
|
+
#scheduleFlush(key) {
|
|
68
|
+
return setTimeout(() => {
|
|
69
|
+
this.#flush(key);
|
|
70
|
+
}, this.#debounceMs);
|
|
71
|
+
}
|
|
72
|
+
#flush(key) {
|
|
73
|
+
const pending = this.#pending.get(key);
|
|
74
|
+
if (!pending)
|
|
75
|
+
return;
|
|
76
|
+
this.#pending.delete(key);
|
|
77
|
+
const merged = this.#mergeMessages(pending);
|
|
78
|
+
pending.resolve(merged);
|
|
79
|
+
}
|
|
80
|
+
#mergeMessages(pending) {
|
|
81
|
+
const base = pending.message;
|
|
82
|
+
const texts = pending.texts.filter(Boolean);
|
|
83
|
+
if (texts.length === 0) {
|
|
84
|
+
return base;
|
|
85
|
+
}
|
|
86
|
+
if (texts.length === 1) {
|
|
87
|
+
return { ...base, text: texts[0] };
|
|
88
|
+
}
|
|
89
|
+
const mergedText = texts.join('\n');
|
|
90
|
+
return { ...base, text: mergedText };
|
|
91
|
+
}
|
|
92
|
+
}
|