@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,261 @@
1
+ /**
2
+ * Daemon çekirdeği: tek `AjanService`, seri işlem kuyruğu, olay yayını.
3
+ *
4
+ * Model belleği ve bağlam tek örnek üzerinden paylaşılır; bu yüzden modele
5
+ * dokunan istekler (chat, install, use-model, set-config) sıraya girer.
6
+ * Durum okuyan istekler (status, list) kuyruğu atlar.
7
+ */
8
+ import { AjanService, type ServiceEvents } from "../service.js";
9
+ import type { ChatHistoryItem } from "node-llama-cpp";
10
+ import type { EventFrame, HistoryMessage, StatusReport } from "../protocol/messages.js";
11
+ import { PROTOCOL_VERSION } from "../protocol/messages.js";
12
+ import { buildGpuReport } from "./gpu.js";
13
+ import { AJAN_VERSION } from "../version.js";
14
+
15
+ type Listener = (event: EventFrame) => void;
16
+
17
+ /** Modele dokunan işlemler bu listede sırayla çalışır. */
18
+ type ExclusiveTask = () => Promise<void>;
19
+
20
+ export class Runner {
21
+ private readonly service: AjanService;
22
+ private readonly listeners = new Set<Listener>();
23
+ private queue: Promise<void> = Promise.resolve();
24
+ private busy = false;
25
+
26
+ /** Sohbet dışı olayların varsayılan yayıncısı (model yükleme, durum) */
27
+ private readonly baseEvents: ServiceEvents;
28
+
29
+ /** Ajanın çalışma dizini (durum raporunda döner). */
30
+ readonly cwd: string;
31
+
32
+ constructor(cwd: string) {
33
+ this.cwd = cwd;
34
+ const events: ServiceEvents = {
35
+ onTextChunk: (text) => this.emit({ event: "text-chunk", text }),
36
+ onThinkingChunk: (text) => this.emit({ event: "thinking-chunk", text }),
37
+ onToolCall: (name, args) => this.emit({ event: "tool-call", name, args: (args ?? {}) as Record<string, unknown> }),
38
+ onToolResult: (name, ok, output) => this.emit({ event: "tool-result", name, ok, output }),
39
+ onStepStart: (step, maxSteps) => this.emit({ event: "step-start", step, maxSteps }),
40
+ onStepEnd: (step) => this.emit({ event: "step-end", step }),
41
+ // `updateEvents` birleştirdiği için sohbet bitince bu geri yüklenmezse
42
+ // `onError` bir sonraki hatada kapalı socket'e gider. Şimdiden tanımlı olmalı.
43
+ onError: (err) => this.emit({ event: "error", message: err.message }),
44
+ onModelLoadProgress: (pct) => this.emit({ event: "load-progress", pct }),
45
+ onModelLoadComplete: () => this.emit({ event: "load-complete" })
46
+ };
47
+ this.baseEvents = events;
48
+ this.service = new AjanService(events, cwd);
49
+ }
50
+
51
+ get instance(): AjanService {
52
+ return this.service;
53
+ }
54
+
55
+ subscribe(listener: Listener): () => void {
56
+ this.listeners.add(listener);
57
+ return () => this.listeners.delete(listener);
58
+ }
59
+
60
+ get clientCount(): number {
61
+ return this.listeners.size;
62
+ }
63
+
64
+ get isBusy(): boolean {
65
+ return this.busy;
66
+ }
67
+
68
+ private emit(event: EventFrame): void {
69
+ for (const listener of this.listeners) {
70
+ try {
71
+ listener(event);
72
+ } catch {
73
+ /* dinleyici hatası istemciyi düşürmemeli */
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Modele dokunan işlemleri sıraya alır; işlem bitene kadar bekler. */
79
+ private exclusive<T>(task: () => Promise<T>): Promise<T> {
80
+ const run = this.queue.then(async () => {
81
+ this.busy = true;
82
+ try {
83
+ return await task();
84
+ } finally {
85
+ this.busy = false;
86
+ }
87
+ });
88
+ // Kuyruğun kendisi asla reddedilmesin; bir işlem hata verirse sonrakiler yine aksın.
89
+ this.queue = run.then(
90
+ () => undefined,
91
+ () => undefined
92
+ );
93
+ return run;
94
+ }
95
+
96
+ // ── İşlemler ──
97
+
98
+ /**
99
+ * Sohbet gönderir. Çıktı akışı yalnızca `target` alıcısına gider: motor
100
+ * paylaşılan, yanıt akışı değil. `target` verilmezse tüm bağlı istemcilere
101
+ * yayın yapılır (tek istemcili kullanımda aynı davranış).
102
+ */
103
+ async sendMessage(
104
+ text: string,
105
+ modelId?: string,
106
+ target?: Listener
107
+ ): Promise<{ ok: boolean; response?: string; steps?: number; error?: string }> {
108
+ return this.exclusive(async () => {
109
+ // Model yükleme ilerlemesi herkese açık: yükleme motor genelinde
110
+ // tek seferlik bir olaydır, sohbetten bağımsız.
111
+ const chatEvents: ServiceEvents = {
112
+ onTextChunk: (t) => this.to(target, { event: "text-chunk", text: t }),
113
+ onThinkingChunk: (t) => this.to(target, { event: "thinking-chunk", text: t }),
114
+ onToolCall: (name, args) =>
115
+ this.to(target, { event: "tool-call", name, args: (args ?? {}) as Record<string, unknown> }),
116
+ onToolResult: (name, ok, output) => this.to(target, { event: "tool-result", name, ok, output }),
117
+ onStepStart: (step, maxSteps) => this.to(target, { event: "step-start", step, maxSteps }),
118
+ onStepEnd: (step) => this.to(target, { event: "step-end", step }),
119
+ onError: (err) => this.to(target, { event: "error", message: err.message }),
120
+ onModelLoadProgress: (pct) => this.emit({ event: "load-progress", pct }),
121
+ onModelLoadComplete: () => this.emit({ event: "load-complete" })
122
+ };
123
+ this.service.updateEvents(chatEvents);
124
+ try {
125
+ const result = await this.service.sendMessage(text, modelId);
126
+ if (!result.ok) this.to(target, { event: "error", message: result.error ?? "bilinmeyen hata" });
127
+ else this.to(target, { event: "complete", response: result.response ?? "", steps: result.steps ?? 0 });
128
+ return result;
129
+ } finally {
130
+ // Yayını eski hedefe döndür: istek bittiğinde olaylar kalıcı olarak
131
+ // tek istemciye bağlı kalmasın.
132
+ this.service.updateEvents(this.baseEvents);
133
+ }
134
+ });
135
+ }
136
+
137
+ private to(target: Listener | undefined, event: EventFrame): void {
138
+ if (target) target(event);
139
+ else this.emit(event);
140
+ }
141
+
142
+ setConfig(patch: Record<string, unknown>): Promise<void> {
143
+ return this.exclusive(() => this.service.setConfig(patch));
144
+ }
145
+
146
+ useModel(id: string): Promise<{ ok: boolean; error?: string }> {
147
+ return this.exclusive(() => this.service.useModel(id));
148
+ }
149
+
150
+ installModel(id: string): Promise<{ ok: boolean; error?: string }> {
151
+ return this.exclusive(() => this.service.installModel(id));
152
+ }
153
+
154
+ removeModel(id: string): Promise<{ ok: boolean; error?: string }> {
155
+ return this.exclusive(() => this.service.removeModel(id));
156
+ }
157
+
158
+ refreshModels(): Promise<{ ok: boolean; count?: number; error?: string }> {
159
+ return this.exclusive(() => this.service.refreshModels());
160
+ }
161
+
162
+ reset(): Promise<void> {
163
+ return this.exclusive(() => this.service.reset());
164
+ }
165
+
166
+ abort(): void {
167
+ this.service.abort();
168
+ }
169
+
170
+ listModels() {
171
+ return this.service.listModels();
172
+ }
173
+
174
+ listSessions() {
175
+ return this.service.listSessions();
176
+ }
177
+
178
+ loadSession(id: string) {
179
+ return this.service.loadSession(id);
180
+ }
181
+
182
+ saveSession(session: Parameters<AjanService["saveSession"]>[0]) {
183
+ return this.service.saveSession(session);
184
+ }
185
+
186
+ deleteSession(id: string) {
187
+ return this.service.deleteSession(id);
188
+ }
189
+
190
+ getHistory(): HistoryMessage[] {
191
+ return toHistoryMessages(this.service.getChatHistory());
192
+ }
193
+
194
+ setHistory(history: ChatHistoryItem[]): void {
195
+ this.service.setChatHistory(history);
196
+ }
197
+
198
+ async status(): Promise<StatusReport> {
199
+ const model = this.service.getModelInfo();
200
+ const config = this.service.getConfig();
201
+ return {
202
+ protocol: PROTOCOL_VERSION,
203
+ version: AJAN_VERSION,
204
+ modelId: config.modelId,
205
+ modelName: model?.name ?? config.modelId,
206
+ installed: model?.installed ?? false,
207
+ loading: false,
208
+ busy: this.busy,
209
+ cwd: this.cwd,
210
+ gpu: await buildGpuReport(this.service),
211
+ context: this.service.getContextStats(),
212
+ reasoning: this.service.getReasoningInfo(),
213
+ config,
214
+ clients: this.clientCount,
215
+ uptimeSec: Math.round(process.uptime())
216
+ };
217
+ }
218
+
219
+ async dispose(): Promise<void> {
220
+ await this.service.dispose();
221
+ }
222
+ }
223
+
224
+ /**
225
+ * node-llama-cpp geçmişini istemciye gönderilebilir sade biçime çevirir.
226
+ * Düşünme segmentleri ayrı `thought` mesajı olarak ayrılır; araç çağrıları
227
+ * `tool` türüne düşer.
228
+ */
229
+ export function toHistoryMessages(history: ChatHistoryItem[]): HistoryMessage[] {
230
+ const out: HistoryMessage[] = [];
231
+ for (const item of history) {
232
+ if (item.type === "user") {
233
+ out.push({ kind: "user", text: textOf(item.text) });
234
+ continue;
235
+ }
236
+ if (item.type !== "model") continue;
237
+ let text = "";
238
+ let thought = "";
239
+ for (const part of item.response) {
240
+ if (typeof part === "string") {
241
+ text += part;
242
+ continue;
243
+ }
244
+ if (part.type === "segment") {
245
+ if (part.segmentType === "thought") thought += part.text;
246
+ else text += part.text;
247
+ continue;
248
+ }
249
+ if (part.type === "functionCall") {
250
+ out.push({ kind: "tool", text: part.name, name: part.name });
251
+ }
252
+ }
253
+ if (thought) out.push({ kind: "thought", text: thought });
254
+ if (text) out.push({ kind: "assistant", text });
255
+ }
256
+ return out;
257
+ }
258
+
259
+ function textOf(value: unknown): string {
260
+ return typeof value === "string" ? value : JSON.stringify(value);
261
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * IPC sunucusu: named pipe / unix socket üzerinden dinler, istekleri `Runner`'a
3
+ * yönlendirir, motor olaylarını tüm bağlı istemcilere yayar.
4
+ *
5
+ * Bağlantı başına ayrı oturum durumu tutulur; model ise tek ve paylaşılan.
6
+ */
7
+ import { createServer, type Server, type Socket } from "node:net";
8
+ import { existsSync, unlinkSync } from "node:fs";
9
+ import { defaultSocketPath, send, serveChannel } from "../protocol/channel.js";
10
+ import { isRequestFrame, type Result, type ServerFrame } from "../protocol/messages.js";
11
+ import { AJAN_VERSION } from "../version.js";
12
+ import { PROTOCOL_VERSION } from "../protocol/messages.js";
13
+ import type { Runner } from "./runner.js";
14
+ import { logger } from "../utils/paths.js";
15
+
16
+ /** İstemcinin kopmasından kaynaklanan, loglanmaya değmeyen hatalar. */
17
+ function isDisconnect(err: Error): boolean {
18
+ const code = (err as NodeJS.ErrnoException).code;
19
+ return code === "EPIPE" || code === "ECONNRESET" || code === "ERR_STREAM_DESTROYED";
20
+ }
21
+
22
+ export class DaemonServer {
23
+ private readonly server: Server;
24
+ private readonly sockets = new Set<Socket>();
25
+ private closing = false;
26
+
27
+ constructor(
28
+ private readonly runner: Runner,
29
+ private readonly socketPath: string = defaultSocketPath()
30
+ ) {
31
+ this.server = createServer((socket) => this.accept(socket));
32
+ }
33
+
34
+ get path(): string {
35
+ return this.socketPath;
36
+ }
37
+
38
+ get clientCount(): number {
39
+ return this.sockets.size;
40
+ }
41
+
42
+ /** Sunucuyu dinlemeye başlar; eski socket artığını temizler. */
43
+ listen(): Promise<void> {
44
+ // POSIX'te çökmüş bir daemon bırakırsa socket dosyası kalır ve
45
+ // "EADDRINUSE" verir; sahipsizse güvenle silinebilir.
46
+ if (process.platform !== "win32" && existsSync(this.socketPath)) {
47
+ try {
48
+ unlinkSync(this.socketPath);
49
+ } catch {
50
+ /* başka bir süreç kullanıyorsa dokunma */
51
+ }
52
+ }
53
+ return new Promise((resolve, reject) => {
54
+ this.server.once("error", reject);
55
+ this.server.listen(this.socketPath, () => {
56
+ this.server.removeListener("error", reject);
57
+ logger.info(`AJAN motoru dinliyor: ${this.socketPath}`);
58
+ resolve();
59
+ });
60
+ });
61
+ }
62
+
63
+ private accept(socket: Socket): void {
64
+ this.sockets.add(socket);
65
+ const write = (frame: ServerFrame) => {
66
+ if (socket.destroyed) return;
67
+ // İstemci kapanmak normaldir (kapanış yarışı, `close()` çağrısı).
68
+ // Yazma hatası kullanıcıyı rahatsız edecek gürültü yaratmasın; soket
69
+ // zaten `close` dinleyicisinde temizleniyor.
70
+ try {
71
+ send(socket, frame);
72
+ } catch {
73
+ /* bağlantı koptu */
74
+ }
75
+ };
76
+ // Olaylar tüm istemcilere gider: motor tek, sohbetler ayrı değil.
77
+ // (İleride oturum bazlı yönlendirme gerekiyorsa burada hedeflenir.)
78
+ const unsubscribe = this.runner.subscribe(write);
79
+
80
+ serveChannel(
81
+ socket,
82
+ (frame) => {
83
+ const valid = acceptFrame(frame);
84
+ if (!valid) {
85
+ logger.warn("geçersiz istek zarfı yok sayıldı");
86
+ return;
87
+ }
88
+ void this.dispatch(valid, write);
89
+ },
90
+ (err) => {
91
+ if (isDisconnect(err)) return; // EPIPE/ECONNRESET: normal kapanış
92
+ logger.warn(`kanal hatası: ${err.message}`);
93
+ }
94
+ );
95
+
96
+ socket.on("close", () => {
97
+ unsubscribe();
98
+ this.sockets.delete(socket);
99
+ logger.debug(`istemci ayrıldı (kalan: ${this.sockets.size})`);
100
+ });
101
+ socket.on("error", () => socket.destroy());
102
+ logger.debug(`istemci bağlandı (toplam: ${this.sockets.size})`);
103
+ }
104
+
105
+ private async dispatch(frame: { id: number; req: { type: string } & Record<string, unknown> }, write: (f: ServerFrame) => void): Promise<void> {
106
+ const id = frame.id;
107
+ const req = frame.req;
108
+ const reply = (ok: true, result: Result) => write({ id, ok, result });
109
+ const fail = (error: string) => write({ id, ok: false, error });
110
+
111
+ try {
112
+ switch (req.type) {
113
+ case "ping": {
114
+ reply(true, { type: "pong", protocol: PROTOCOL_VERSION, version: AJAN_VERSION });
115
+ break;
116
+ }
117
+ case "status":
118
+ reply(true, { type: "status", status: await this.runner.status() });
119
+ break;
120
+ case "set-config": {
121
+ await this.runner.setConfig(req.patch as Record<string, unknown>);
122
+ reply(true, { type: "config", config: this.runner.instance.getConfig() });
123
+ break;
124
+ }
125
+ case "list-models": {
126
+ const models = this.runner.listModels().map((m) => ({ ...m.model, path: m.path, installed: m.installed, active: m.active }));
127
+ reply(true, { type: "models", models });
128
+ break;
129
+ }
130
+ case "install-model": {
131
+ const r = await this.runner.installModel(req.id as string);
132
+ reply(true, { type: "model", id: String(req.id), ok: r.ok, error: r.error });
133
+ break;
134
+ }
135
+ case "remove-model": {
136
+ const r = await this.runner.removeModel(req.id as string);
137
+ reply(true, { type: "model", id: String(req.id), ok: r.ok, error: r.error });
138
+ break;
139
+ }
140
+ case "use-model": {
141
+ const r = await this.runner.useModel(req.id as string);
142
+ reply(true, { type: "model", id: String(req.id), ok: r.ok, error: r.error });
143
+ break;
144
+ }
145
+ case "refresh-models": {
146
+ const r = await this.runner.refreshModels();
147
+ reply(true, { type: "models-refreshed", ok: r.ok, count: r.count ?? 0, error: r.error });
148
+ break;
149
+ }
150
+ case "chat": {
151
+ // Asıl yanıt akışla gelir (text-chunk/complete olayları).
152
+ // Bu kanal yalnızca görevin bittiğini teyit eder; başarısızlık
153
+ // durumunda hata zarfı döner, aksi halde istemci asılı kalırdı.
154
+ // Sohbet çıktısı yalnızca soran bağlantıya gider (`write`),
155
+ // diğer istemcilere sızmasın.
156
+ const r = await this.runner.sendMessage(String(req.text ?? ""), req.modelId as string | undefined, write);
157
+ if (!r.ok) fail(r.error ?? "görev başarısız");
158
+ else reply(true, { type: "ok" });
159
+ break;
160
+ }
161
+ case "abort":
162
+ this.runner.abort();
163
+ reply(true, { type: "ok" });
164
+ break;
165
+ case "new-chat": {
166
+ await this.runner.reset();
167
+ reply(true, { type: "ok" });
168
+ break;
169
+ }
170
+ case "get-history":
171
+ reply(true, { type: "history", messages: this.runner.getHistory() });
172
+ break;
173
+ case "list-sessions": {
174
+ const sessions = await this.runner.listSessions();
175
+ reply(true, {
176
+ type: "sessions",
177
+ sessions: sessions.map((s) => ({ id: s.id, title: s.title, updatedAt: s.updatedAt }))
178
+ });
179
+ break;
180
+ }
181
+ case "load-session": {
182
+ const session = await this.runner.loadSession(String(req.id));
183
+ reply(true, { type: "session", session, ok: session != null });
184
+ break;
185
+ }
186
+ case "save-session": {
187
+ await this.runner.saveSession(req.session as never);
188
+ reply(true, { type: "ok" });
189
+ break;
190
+ }
191
+ case "delete-session": {
192
+ await this.runner.deleteSession(String(req.id));
193
+ reply(true, { type: "ok" });
194
+ break;
195
+ }
196
+ case "shutdown": {
197
+ reply(true, { type: "shutdown", ok: true });
198
+ void this.close();
199
+ break;
200
+ }
201
+ default:
202
+ fail(`bilinmeyen istek: ${String(req.type)}`);
203
+ }
204
+ } catch (err) {
205
+ fail((err as Error).message);
206
+ }
207
+ }
208
+
209
+ async close(): Promise<void> {
210
+ if (this.closing) return;
211
+ this.closing = true;
212
+ for (const socket of this.sockets) socket.end();
213
+ this.sockets.clear();
214
+ await new Promise<void>((resolve) => this.server.close(() => resolve()));
215
+ await this.runner.dispose();
216
+ if (process.platform !== "win32" && existsSync(this.socketPath)) {
217
+ try {
218
+ unlinkSync(this.socketPath);
219
+ } catch {
220
+ /* temizlik başarısız olsa da çıkışı engellememeli */
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ /** İstek zarfının gerçekten geçerli olduğunu doğrular; geçersizse null döner. */
227
+ export function acceptFrame(value: unknown): { id: number; req: { type: string } & Record<string, unknown> } | null {
228
+ if (!isRequestFrame(value)) return null;
229
+ const f = value as { id: number; req: { type: string } & Record<string, unknown> };
230
+ return f;
231
+ }