@takimcizgisi/ajan 0.5.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.
@@ -0,0 +1,329 @@
1
+ import {
2
+ getLlama,
3
+ getLlamaGpuTypes,
4
+ LlamaChatSession,
5
+ Gemma4ChatWrapper,
6
+ GemmaChatWrapper,
7
+ QwenChatWrapper,
8
+ Llama3_1ChatWrapper,
9
+ MistralChatWrapper,
10
+ ChatMLChatWrapper,
11
+ FunctionaryChatWrapper,
12
+ LlamaLogLevel,
13
+ type Llama,
14
+ type LlamaModel,
15
+ type LlamaContext,
16
+ type LlamaContextSequence,
17
+ type ChatWrapper,
18
+ type ChatSessionModelFunctions
19
+ } from "node-llama-cpp";
20
+ import type { AjanConfig, ChatWrapperName } from "../config/types.js";
21
+ import { logger } from "../utils/paths.js";
22
+
23
+ /** Motorun gerçekten üzerinde çalıştığı arka uç */
24
+ export type ActiveGpu = {
25
+ /** "cpu" | "cuda" | "vulkan" | "metal" */
26
+ backend: string;
27
+ /** Katman aktarımı açık mı */
28
+ offload: boolean;
29
+ };
30
+
31
+ export type LlamaEngineOptions = {
32
+ modelPath: string;
33
+ config: AjanConfig;
34
+ chatWrapperName?: ChatWrapperName;
35
+ /** Düşünme (thought) üretimi açık mı; belirtilmezse sarmalayıcının varsayılanı. */
36
+ reasoning?: boolean;
37
+ /** Modele özel bağlam penceresi; yoksa config.contextSize kullanılır. */
38
+ contextSize?: AjanConfig["contextSize"];
39
+ /** Modele özel GPU katman sayısı; yoksa config.gpuLayers kullanılır. */
40
+ gpuLayers?: AjanConfig["gpuLayers"];
41
+ gpuBackend?: AjanConfig["gpu"];
42
+ onProgress?: (progress: number) => void;
43
+ signal?: AbortSignal;
44
+ };
45
+
46
+ export class LlamaEngine {
47
+ private llama: Llama | undefined;
48
+ private model: LlamaModel | undefined;
49
+ private context: LlamaContext | undefined;
50
+ private sequence: LlamaContextSequence | undefined;
51
+ private session: LlamaChatSession | undefined;
52
+ private readonly modelPath: string;
53
+ private readonly config: AjanConfig;
54
+ private readonly chatWrapperName?: ChatWrapperName;
55
+ /** Katalog + kullanıcı geçersiz kılmasından çözülmüş düşünme modu */
56
+ private readonly reasoning?: boolean;
57
+ private readonly contextSizeOption: AjanConfig["contextSize"];
58
+ private readonly gpuLayersOption: AjanConfig["gpuLayers"];
59
+ private readonly gpuBackend?: AjanConfig["gpu"];
60
+ private readonly onProgress?: (progress: number) => void;
61
+ private readonly signal?: AbortSignal;
62
+
63
+ constructor(options: LlamaEngineOptions) {
64
+ this.modelPath = options.modelPath;
65
+ this.config = options.config;
66
+ this.chatWrapperName = options.chatWrapperName;
67
+ this.reasoning = options.reasoning;
68
+ // Katalogdaki "auto" bir sayı değil, "config'e uy" demek: aksi halde
69
+ // modelin eğitim bağlamı (örn. 128K) CPU'da dev KV cache'e yol açıyor.
70
+ const modelContext = options.contextSize;
71
+ this.contextSizeOption = modelContext && modelContext !== "auto" ? modelContext : options.config.contextSize;
72
+ const modelLayers = options.gpuLayers;
73
+ this.gpuLayersOption = modelLayers && modelLayers !== "auto" ? modelLayers : options.config.gpuLayers;
74
+ this.gpuBackend = options.gpuBackend ?? options.config.gpu;
75
+ this.onProgress = options.onProgress;
76
+ this.signal = options.signal;
77
+ }
78
+
79
+ /**
80
+ * config.gpu değerini node-llama-cpp'in kabul ettiği arka uca çevirir.
81
+ * Önceden otomotik dışındaki seçimler (cuda/vulkan/metal) sessizce yok sayılıyordu;
82
+ * config'de açıkça seçilen arka uç artık motora iletilir.
83
+ */
84
+ private get gpuOption(): "auto" | "cuda" | "vulkan" | "metal" | undefined {
85
+ switch (this.gpuBackend) {
86
+ case "cuda":
87
+ case "cuda-llama":
88
+ return "cuda";
89
+ case "vulkan":
90
+ case "vulkan-llama":
91
+ return "vulkan";
92
+ case "metal":
93
+ return "metal";
94
+ case "auto":
95
+ default:
96
+ // webgpu vb. bu node-llama-cpp sürümünde desteklenmiyor; auto'ya düş.
97
+ return "auto";
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Motorun deneyeceği arka uç sırası.
103
+ *
104
+ * Yalnızca CUDA ve Vulkan desteklenir. Sıra config.gpu ile başlar:
105
+ * kullanıcı "vulkan" dediyse CUDA denemesi yapılmaz, "cuda" dediyse
106
+ * CUDA denenir ve olmazsa Vulkan'a düşülür. Sondaki "auto" node-llama-cpp'ın
107
+ * kendi çözümlemesidir (CUDA yoksa Vulkan, o da yoksa CPU).
108
+ */
109
+ private get gpuFallbackChain(): ("auto" | "cuda" | "vulkan")[] {
110
+ const wanted = this.gpuOption;
111
+ if (wanted === "cuda") return ["cuda", "vulkan", "auto"];
112
+ if (wanted === "vulkan") return ["vulkan", "auto"];
113
+ if (wanted === "metal") return ["auto"];
114
+ return ["auto"];
115
+ }
116
+
117
+ static async detectSupportedGpus(): Promise<string[]> {
118
+ try {
119
+ const supported = await getLlamaGpuTypes("supported");
120
+ return supported.filter((g): g is "metal" | "cuda" | "vulkan" => typeof g === "string");
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Gerçekten yüklenen arka ucu döndürür.
128
+ *
129
+ * `detectSupportedGpus()` yalnızca "makine bu arka ucu destekliyor mu"
130
+ * diye bakar; kurulu build'de o pakete ait ikili yoksa node-llama-cpp
131
+ * sessizce CPU'ya düşer ve istemci yanlışlıkla GPU kullanıldığını sanar. `Llama.gpu`
132
+ * ise yüklenen gerçek değerdir.
133
+ */
134
+ get activeGpu(): ActiveGpu | null {
135
+ const llama = this.llama;
136
+ if (!llama) return null;
137
+ return {
138
+ backend: llama.gpu === false ? "cpu" : llama.gpu,
139
+ offload: llama.supportsGpuOffloading
140
+ };
141
+ }
142
+
143
+ /** Kullanılabilen GPU belleği (MB); CPU modunda 0 döner. */
144
+ async gpuVramMb(): Promise<number> {
145
+ if (!this.llama || this.llama.gpu === false) return 0;
146
+ try {
147
+ const info = await this.llama.getLlamaMemoryUsage();
148
+ return Math.round(info.gpuVram / (1024 * 1024));
149
+ } catch {
150
+ return 0;
151
+ }
152
+ }
153
+
154
+ async init(): Promise<void> {
155
+ if (this.llama) return;
156
+ logger.info(`Motor baslatiliyor... (model: ${this.modelPath})`);
157
+ const supportedGpus = await LlamaEngine.detectSupportedGpus();
158
+ logger.debug(`Desteklenen GPU'lar: ${supportedGpus.join(", ") || "yok (CPU)"}`);
159
+
160
+ // Sırayla dene: ilk yüklenen kazanır. Sessizce CPU'ya düşmüyoruz —
161
+ // kullanıcı hangi adaya geçildiğini ve nedenini görsün.
162
+ const chain = this.gpuFallbackChain;
163
+ const failures: string[] = [];
164
+ for (const candidate of chain) {
165
+ try {
166
+ this.llama = await getLlama({
167
+ gpu: candidate,
168
+ logLevel: LlamaLogLevel.error,
169
+ progressLogs: "stderr",
170
+ skipDownload: true
171
+ });
172
+ this.reportGpuChoice(candidate, chain.length > 1, supportedGpus);
173
+ return;
174
+ } catch (err) {
175
+ const reason = firstLine((err as Error).message);
176
+ failures.push(`${candidate}: ${reason}`);
177
+ logger.warn(`${candidate} kullanilamadi (${reason})`);
178
+ }
179
+ }
180
+
181
+ // Zincirin tamamı tuttu. Sessiz yavaşlamak yerine açıkça hata ver.
182
+ throw new Error(
183
+ `GPU motoru baslatilamadi. Denenenler -> ${failures.join(" | ")}`
184
+ );
185
+ }
186
+
187
+ /**
188
+ * İstenen arka uç ile gerçekte yüklenen arka uç farklıysa gürültü atma,
189
+ * logla. Kullanıcı "CUDA seçtim ama CPU'da çalışıyor" krizini önceden görsün.
190
+ */
191
+ private reportGpuChoice(loaded: string, hadFallbacks: boolean, supportedGpus: string[]): void {
192
+ const active = this.activeGpu?.backend;
193
+ if (!active) return;
194
+ if (active === "cpu") {
195
+ logger.warn(
196
+ `GPU kullanilamiyor, CPU uzerinde calisiliyor. ` +
197
+ `istenen: ${this.gpuOption} · makinede: ${supportedGpus.join(", ") || "yok"}`
198
+ );
199
+ return;
200
+ }
201
+ if (!hadFallbacks) return;
202
+ if (loaded === "auto" && active !== this.gpuOption) {
203
+ logger.warn(`istenen arka uc ${this.gpuOption}, yuklenen ${active}`);
204
+ } else {
205
+ logger.info(`GPU arka ucu: ${active}`);
206
+ }
207
+ }
208
+
209
+ /** Modelin kaç katmanını GPU'ya yükledi (0 = tamamen CPU'da). */
210
+ get gpuLayersUsed(): number {
211
+ return this.model?.gpuLayers ?? 0;
212
+ }
213
+
214
+ async loadModel(onProgress?: (pct: number) => void): Promise<void> {
215
+ await this.init();
216
+ if (this.model) return;
217
+ logger.info(`Model yukleniyor: ${this.modelPath}`);
218
+
219
+ this.model = await this.llama!.loadModel({
220
+ modelPath: this.modelPath,
221
+ gpuLayers: this.gpuLayersOption ?? "auto",
222
+ onLoadProgress: (pct) => {
223
+ const percentage = Math.min(100, Math.max(0, Math.round(pct * 100)));
224
+ onProgress?.(percentage);
225
+ this.onProgress?.(percentage);
226
+ },
227
+ loadSignal: this.signal
228
+ });
229
+ }
230
+
231
+ async createContext(): Promise<void> {
232
+ await this.loadModel();
233
+ if (this.context) return;
234
+ this.context = await this.model!.createContext({
235
+ contextSize: this.contextSizeOption ?? "auto"
236
+ });
237
+ this.sequence = this.context.getSequence();
238
+ }
239
+
240
+ createSession(systemPrompt: string): LlamaChatSession {
241
+ if (!this.sequence) throw new Error("Oturum olusturmadan once context olusturulmali (createContext)");
242
+ const wrapper = resolveChatWrapper(this.chatWrapperName ?? "auto", this.reasoning);
243
+ this.session = new LlamaChatSession({
244
+ contextSequence: this.sequence,
245
+ systemPrompt,
246
+ chatWrapper: wrapper
247
+ });
248
+ return this.session;
249
+ }
250
+
251
+ /** Konuşma geçmişini temizler ve yeni sistem promptu ile taze bir oturum kurar */
252
+ async resetSession(systemPrompt: string): Promise<LlamaChatSession> {
253
+ if (!this.sequence) throw new Error("Oturum olusturmadan once context olusturulmali (createContext)");
254
+ await this.session?.dispose();
255
+ await this.sequence.clearHistory();
256
+ return this.createSession(systemPrompt);
257
+ }
258
+
259
+ getSession(): LlamaChatSession | undefined {
260
+ return this.session;
261
+ }
262
+
263
+ get contextSize(): number {
264
+ return this.context?.contextSize ?? 0;
265
+ }
266
+
267
+ getUsedContextTokens(): number {
268
+ return this.sequence?.nextTokenIndex ?? 0;
269
+ }
270
+
271
+ get sequenceInstalled(): boolean {
272
+ return this.sequence != null;
273
+ }
274
+
275
+ async dispose(): Promise<void> {
276
+ try {
277
+ this.session?.dispose();
278
+ this.context?.dispose();
279
+ this.model?.dispose();
280
+ this.llama?.dispose();
281
+ } catch {
282
+ /* dispose hatalari yut */
283
+ }
284
+ this.session = undefined;
285
+ this.context = undefined;
286
+ this.model = undefined;
287
+ this.llama = undefined;
288
+ this.sequence = undefined;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Chat sarmalayıcısını kurar.
294
+ *
295
+ * Düşünme seçeneği her sarmalayıcıda farklı ad taşır: Gemma4'te `reasoning`,
296
+ * Qwen3'te `thoughts`. Seçeneği olmayan sarmalayıcılara dokunulmaz, böylece
297
+ * kapatılmış bir düşünme onların şablonunu bozmaz.
298
+ */
299
+ function resolveChatWrapper(name: ChatWrapperName, reasoning?: boolean): "auto" | ChatWrapper {
300
+ switch (name) {
301
+ case "gemma4":
302
+ return new Gemma4ChatWrapper({ reasoning: reasoning ?? true });
303
+ case "gemma":
304
+ return new GemmaChatWrapper();
305
+ case "qwen3":
306
+ // QwenChatWrapper boolean kabul etmez: "discourage" modeli düşünmekten
307
+ // vazgeçirir, "auto" normal davranışı verir.
308
+ return new QwenChatWrapper({ thoughts: reasoning === false ? "discourage" : "auto" });
309
+ case "llama3":
310
+ return new Llama3_1ChatWrapper();
311
+ case "mistral":
312
+ return new MistralChatWrapper();
313
+ case "chatml":
314
+ return new ChatMLChatWrapper();
315
+ case "functionary":
316
+ return new FunctionaryChatWrapper();
317
+ case "auto":
318
+ default:
319
+ return "auto";
320
+ }
321
+ }
322
+
323
+ export type { ChatSessionModelFunctions };
324
+
325
+ /** Hata mesajının ilk satırı; node-llama-cpp çok satırlı hata veriyor. */
326
+ function firstLine(message: string): string {
327
+ const line = message.split("\n").find((l) => l.trim().length > 0) ?? message;
328
+ return line.trim().slice(0, 160);
329
+ }
@@ -0,0 +1,84 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { createModelDownloader } from "node-llama-cpp";
3
+ import type { ModelDefinition } from "../config/types.js";
4
+ import { resolveModelFilePath } from "../config/configManager.js";
5
+ import { getModelsDir } from "../utils/paths.js";
6
+ import { logger } from "../utils/paths.js";
7
+
8
+ export type DownloadProgress = {
9
+ totalSize: number;
10
+ downloadedSize: number;
11
+ estimatedTimeLeft: number;
12
+ averageSpeed: number;
13
+ percent?: number;
14
+ };
15
+
16
+ export type DownloadOptions = {
17
+ onProgress?: (progress: DownloadProgress) => void;
18
+ signal?: AbortSignal;
19
+ };
20
+
21
+ export function formatBytes(bytes: number): string {
22
+ if (!Number.isFinite(bytes) || bytes <= 0) return "?";
23
+ const units = ["B", "KB", "MB", "GB", "TB"];
24
+ let value = bytes;
25
+ let unit = 0;
26
+ while (value >= 1024 && unit < units.length - 1) {
27
+ value /= 1024;
28
+ unit++;
29
+ }
30
+ return `${value.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`;
31
+ }
32
+
33
+ export function formatTime(ms: number): string {
34
+ if (!Number.isFinite(ms) || ms <= 0) return "?";
35
+ const totalSeconds = Math.floor(ms / 1000);
36
+ const h = Math.floor(totalSeconds / 3600);
37
+ const m = Math.floor((totalSeconds % 3600) / 60);
38
+ const s = totalSeconds % 60;
39
+ if (h > 0) return `${h}sa ${m}dk`;
40
+ if (m > 0) return `${m}dk ${s}sn`;
41
+ return `${s}sn`;
42
+ }
43
+
44
+ export async function downloadModel(
45
+ model: ModelDefinition,
46
+ options: DownloadOptions = {}
47
+ ): Promise<string> {
48
+ const dirPath = getModelsDir();
49
+ const targetPath = resolveModelFilePath(model);
50
+
51
+ if (existsSync(targetPath)) {
52
+ logger.info(`Model zaten mevcut: ${targetPath}`);
53
+ return targetPath;
54
+ }
55
+
56
+ logger.info(`Model indiriliyor: ${model.uri}`);
57
+ const downloader = await createModelDownloader({
58
+ modelUri: model.uri,
59
+ dirPath,
60
+ fileName: model.fileName,
61
+ skipExisting: true,
62
+ deleteTempFileOnCancel: false,
63
+ showCliProgress: false,
64
+ onProgress: (status) => {
65
+ options.onProgress?.(status);
66
+ }
67
+ });
68
+
69
+ const downloadedPath = await downloader.download({ signal: options.signal });
70
+ logger.info(`Model indirildi: ${downloadedPath}`);
71
+ return downloadedPath;
72
+ }
73
+
74
+ export function getModelFileSize(path: string): number {
75
+ try {
76
+ return statSync(path).size;
77
+ } catch {
78
+ return 0;
79
+ }
80
+ }
81
+
82
+ export function isDownloaded(model: ModelDefinition): boolean {
83
+ return existsSync(resolveModelFilePath(model));
84
+ }
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ export { AjanAgent, type AgentOptions, type AgentEvents } from "./core/agent.js";
2
+ export { AjanService, type ServiceEvents, type ModelWithStatus } from "./service.js";
3
+ export { LlamaEngine } from "./engine/llama.js";
4
+ export {
5
+ downloadModel,
6
+ formatBytes,
7
+ formatTime,
8
+ isDownloaded,
9
+ getModelFileSize,
10
+ type DownloadProgress
11
+ } from "./engine/modelManager.js";
12
+ export {
13
+ DEFAULT_CONFIG,
14
+ loadConfig,
15
+ saveConfig,
16
+ getModelsRegistry,
17
+ getModelById,
18
+ refreshRemoteModels,
19
+ listInstalledModels,
20
+ isModelInstalled,
21
+ removeModelFile,
22
+ resolveModelFilePath
23
+ } from "./config/configManager.js";
24
+ export { createCoreTools, buildSessionFunctions } from "./tools/registry.js";
25
+ export { parseUnifiedDiff } from "./tools/patch.js";
26
+ export type {
27
+ AjanConfig,
28
+ ModelDefinition,
29
+ RemoteModelsRegistry,
30
+ SessionConfig,
31
+ AgentTool,
32
+ ToolHandler,
33
+ ToolExecutionContext,
34
+ ToolExecutionResult,
35
+ ChatWrapperName,
36
+ GpuBackend,
37
+ ReasoningMode,
38
+ ModelOverride
39
+ } from "./config/types.js";
40
+ export { getDataDir, getModelsDir, getConfigPath, getLogDir, setLogLevel, logger } from "./utils/paths.js";
41
+
42
+ // IPC yüzeyi: tüketiciler motoru `AjanEngine` ile konuşturur, CLI gerekmez.
43
+ export { AjanEngine, EngineError, type AskOptions, type EngineEventListener } from "./client/client.js";
44
+ export {
45
+ PROTOCOL_VERSION,
46
+ isRequestFrame,
47
+ isEventFrame,
48
+ isResponseFrame,
49
+ type Request,
50
+ type RequestType,
51
+ type RequestFrame,
52
+ type ResponseFrame,
53
+ type ServerFrame,
54
+ type EventFrame,
55
+ type EventName,
56
+ type Result,
57
+ type StatusReport,
58
+ type GpuReport,
59
+ type ModelEntry,
60
+ type SessionSummary,
61
+ type HistoryMessage
62
+ } from "./protocol/messages.js";
63
+ export { defaultSocketPath, ClientChannel, serveChannel, send } from "./protocol/channel.js";
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Newline-JSON çerçeveleme.
3
+ *
4
+ * `net.Socket`, `worker_threads` veya `process.stdin` gibi herhangi bir ikili
5
+ * akışı üzerinde çalışır. Yazma tarafı parçalara bölünebilir, okuma tarafı
6
+ * parça sınırlarından bağımsız olarak satır birleştirir.
7
+ */
8
+ import type { RequestFrame, ServerFrame } from "./messages.js";
9
+
10
+ /** Yazılabilir akış: net.Socket veya benzeri herhangi bir duplex bağlantı */
11
+ type Writable = NodeJS.ReadWriteStream & { write: (chunk: string) => unknown };
12
+
13
+ /** Windows named pipe yolu; POSIX'te XDG_RUNTIME_DIR (yoksa tmp) kullanılır. */
14
+ export function defaultSocketPath(app = "ajan-engine"): string {
15
+ if (process.platform === "win32") return `\\\\.\\pipe\\${app}`;
16
+ const dir = process.env.XDG_RUNTIME_DIR || "/tmp";
17
+ return `${dir}/${app}.sock`;
18
+ }
19
+
20
+ /**
21
+ * Sunucu tarafı: bağlantıdan satır satır okur, `onRequest` çağırır.
22
+ * Yazmak için `send` döner.
23
+ */
24
+ export function serveChannel(
25
+ socket: Writable,
26
+ onRequest: (frame: RequestFrame, socket: Writable) => void,
27
+ onError?: (err: Error) => void
28
+ ): void {
29
+ let buffer = "";
30
+ socket.setEncoding?.("utf8");
31
+ socket.on("data", (chunk: string | Buffer) => {
32
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
33
+ // Normalde tek satır gelir; uzun yazılar (diff, patch) bölünmüş gelebilir.
34
+ let index = buffer.indexOf("\n");
35
+ while (index !== -1) {
36
+ const line = buffer.slice(0, index).trim();
37
+ buffer = buffer.slice(index + 1);
38
+ if (line.length > 0) {
39
+ try {
40
+ const parsed = JSON.parse(line) as unknown;
41
+ onRequest(parsed as RequestFrame, socket);
42
+ } catch (err) {
43
+ onError?.(new Error(`bozuk JSON: ${(err as Error).message}`));
44
+ }
45
+ }
46
+ index = buffer.indexOf("\n");
47
+ }
48
+ });
49
+ socket.on("error", (err) => onError?.(err as Error));
50
+ }
51
+
52
+ export function send(socket: Writable, frame: ServerFrame | RequestFrame): void {
53
+ socket.write(JSON.stringify(frame) + "\n");
54
+ }
55
+
56
+ /**
57
+ * İstemci tarafı: yanıt ve olayları ayırır, `id` eşleşen isteğe çözer.
58
+ * Bağlantı kurulmadan bağımsız çalışır; bağlanmayı `connect` yapar.
59
+ */
60
+ export class ClientChannel {
61
+ private buffer = "";
62
+ private nextId = 1;
63
+ private socket: Writable | undefined;
64
+ private readonly pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
65
+
66
+ constructor(
67
+ private readonly onEvent: (e: Extract<ServerFrame, { event: string }>) => void,
68
+ private readonly onClose: (reason?: Error) => void = () => {}
69
+ ) {}
70
+
71
+ /** Sokete bağlar ve akışı dinlemeye başlar. */
72
+ attach(socket: Writable): void {
73
+ this.socket = socket;
74
+ socket.setEncoding?.("utf8");
75
+ socket.on("data", (chunk: string | Buffer) => this.feed(typeof chunk === "string" ? chunk : chunk.toString("utf8")));
76
+ socket.on("error", (err) => {
77
+ // Koptuk: bekleyen istekler sonsuza kadar asılı kalmasın.
78
+ this.failAll((err as Error).message);
79
+ this.onClose(err as Error);
80
+ });
81
+ socket.on("close", () => {
82
+ this.failAll("bağlantı kapandı");
83
+ this.onClose();
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Bağlantıyı kapatır ve bekleyen istekleri reddeder.
89
+ *
90
+ * `end` tek yönlü kapatır; istek yazan taraf da soketi kapatabilsin diye
91
+ * varsa `destroy` ile tamamen yok edilir. Zaten kapanmışsa bir şey yapılmaz.
92
+ */
93
+ close(): void {
94
+ this.failAll("istemci kapatildi");
95
+ const socket = this.socket as (Writable & { destroy?: () => void }) | undefined;
96
+ this.socket = undefined;
97
+ socket?.destroy?.();
98
+ }
99
+
100
+ request(req: RequestFrame["req"]): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
101
+ if (!this.socket) return Promise.resolve({ ok: false, error: "kanal bağlı değil" });
102
+ const id = this.nextId++;
103
+ return new Promise((resolve) => {
104
+ // Kopma halinde asılı kalmayalım: reject değil, hata çözülür.
105
+ this.pending.set(id, {
106
+ resolve: resolve as (v: unknown) => void,
107
+ reject: (e) => resolve({ ok: false, error: e.message })
108
+ });
109
+ this.socket!.write(JSON.stringify({ id, req } satisfies RequestFrame) + "\n");
110
+ });
111
+ }
112
+
113
+ private feed(text: string): void {
114
+ this.buffer += text;
115
+ let index = this.buffer.indexOf("\n");
116
+ while (index !== -1) {
117
+ const line = this.buffer.slice(0, index).trim();
118
+ this.buffer = this.buffer.slice(index + 1);
119
+ if (line.length > 0) this.handle(line);
120
+ index = this.buffer.indexOf("\n");
121
+ }
122
+ }
123
+
124
+ private handle(line: string): void {
125
+ let frame: unknown;
126
+ try {
127
+ frame = JSON.parse(line);
128
+ } catch {
129
+ return; // bozuk satırı yoksay; bağlantıyı düşürme
130
+ }
131
+ const f = frame as { id?: number; event?: string };
132
+ if (typeof f.event === "string") {
133
+ this.onEvent(frame as Extract<ServerFrame, { event: string }>);
134
+ return;
135
+ }
136
+ if (typeof f.id === "number") {
137
+ const entry = this.pending.get(f.id);
138
+ if (!entry) return;
139
+ this.pending.delete(f.id);
140
+ const r = frame as { ok: true; result: unknown } | { ok: false; error: string };
141
+ if (r.ok) entry.resolve({ ok: true, result: r.result });
142
+ else entry.resolve({ ok: false, error: r.error });
143
+ }
144
+ }
145
+
146
+ /** Bağlantı koparken bekleyen istekleri reddet; çağıran takılmasın. */
147
+ failAll(error: string): void {
148
+ for (const [, entry] of this.pending) entry.reject(new Error(error));
149
+ this.pending.clear();
150
+ }
151
+ }