@astra-code/astra-ai 0.1.5 → 0.1.7

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.
@@ -7,7 +7,7 @@ import {
7
7
  } from "./config.js";
8
8
  import type {AgentEvent, AuthSession, ChatMessage} from "../types/events.js";
9
9
  import type {WorkspaceFile} from "./workspaceScanner.js";
10
- import {loadSession} from "./sessionStore.js";
10
+ import {loadSession, saveSession} from "./sessionStore.js";
11
11
 
12
12
  type JsonRecord = Record<string, unknown>;
13
13
 
@@ -22,14 +22,55 @@ export type SessionSummary = {
22
22
  export class BackendClient {
23
23
  private readonly baseUrl: string;
24
24
  private authToken: string | null;
25
+ private refreshToken: string | null;
26
+ private onTokenRefreshed: ((session: AuthSession) => void) | null = null;
25
27
 
26
28
  public constructor(baseUrl = getBackendUrl()) {
27
29
  this.baseUrl = baseUrl;
28
- this.authToken = loadSession()?.access_token?.trim() || null;
30
+ const stored = loadSession();
31
+ this.authToken = stored?.access_token?.trim() || null;
32
+ this.refreshToken = stored?.refresh_token?.trim() || null;
29
33
  }
30
34
 
31
35
  public setAuthSession(session: AuthSession | null): void {
32
36
  this.authToken = session?.access_token?.trim() || null;
37
+ this.refreshToken = session?.refresh_token?.trim() || null;
38
+ }
39
+
40
+ public setOnTokenRefreshed(cb: (session: AuthSession) => void): void {
41
+ this.onTokenRefreshed = cb;
42
+ }
43
+
44
+ private async tryRefreshToken(): Promise<boolean> {
45
+ if (!this.refreshToken) return false;
46
+ try {
47
+ const res = await fetch(`${this.baseUrl}/api/auth/refresh`, {
48
+ method: "POST",
49
+ headers: {"content-type": "application/json"},
50
+ body: JSON.stringify({refresh_token: this.refreshToken})
51
+ });
52
+ if (!res.ok) return false;
53
+ const data = (await res.json()) as Record<string, unknown>;
54
+ const newAccess = typeof data.access_token === "string" ? data.access_token.trim() : null;
55
+ if (!newAccess) return false;
56
+ const newRefresh = typeof data.refresh_token === "string" ? data.refresh_token.trim() : this.refreshToken;
57
+ this.authToken = newAccess;
58
+ this.refreshToken = newRefresh;
59
+ // Merge with stored session and persist
60
+ const stored = loadSession();
61
+ if (stored) {
62
+ const refreshed: AuthSession = {
63
+ ...stored,
64
+ access_token: newAccess,
65
+ refresh_token: newRefresh ?? undefined
66
+ };
67
+ saveSession(refreshed);
68
+ this.onTokenRefreshed?.(refreshed);
69
+ }
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
33
74
  }
34
75
 
35
76
  public async get(path: string, params?: Record<string, string>): Promise<JsonRecord> {
@@ -162,6 +203,24 @@ export class BackendClient {
162
203
  return out;
163
204
  }
164
205
 
206
+ public async getCliSettings(): Promise<Record<string, unknown>> {
207
+ try {
208
+ const data = await this.get("/api/user/cli-settings");
209
+ return (data.cli_settings as Record<string, unknown>) ?? {};
210
+ } catch {
211
+ return {};
212
+ }
213
+ }
214
+
215
+ public async updateCliSettings(settings: Record<string, unknown>): Promise<Record<string, unknown>> {
216
+ try {
217
+ const data = await this.patch("/api/user/cli-settings", settings);
218
+ return (data.cli_settings as Record<string, unknown>) ?? {};
219
+ } catch {
220
+ return {};
221
+ }
222
+ }
223
+
165
224
  public async deleteSession(sessionId: string): Promise<void> {
166
225
  await this.delete(`/api/sessions/${sessionId}`);
167
226
  }
@@ -191,6 +250,7 @@ export class BackendClient {
191
250
  workspaceTree?: string[];
192
251
  workspaceFiles?: WorkspaceFile[];
193
252
  model?: string;
253
+ signal?: AbortSignal;
194
254
  }): AsyncGenerator<AgentEvent, void, void> {
195
255
  const model = payload.model ?? getDefaultModel();
196
256
  const body = {
@@ -212,20 +272,33 @@ export class BackendClient {
212
272
  user_id: payload.user.user_id,
213
273
  org_id: payload.user.org_id
214
274
  };
215
- return this.streamSse("/api/agent/chat/stream", body);
275
+ return this.streamSse("/api/agent/chat/stream", body, payload.signal);
216
276
  }
217
277
 
218
278
  private async request(url: string, method: string, payload?: JsonRecord): Promise<JsonRecord> {
219
- const headers: Record<string, string> = {"content-type": "application/json"};
220
- if (this.authToken) {
221
- headers.authorization = `Bearer ${this.authToken}`;
222
- }
223
- const response = await fetch(url, {
279
+ const makeHeaders = (): Record<string, string> => {
280
+ const h: Record<string, string> = {"content-type": "application/json"};
281
+ if (this.authToken) h.authorization = `Bearer ${this.authToken}`;
282
+ return h;
283
+ };
284
+
285
+ let response = await fetch(url, {
224
286
  method,
225
- headers,
287
+ headers: makeHeaders(),
226
288
  body: payload ? JSON.stringify(payload) : null
227
289
  });
228
290
 
291
+ if (response.status === 401) {
292
+ const refreshed = await this.tryRefreshToken();
293
+ if (refreshed) {
294
+ response = await fetch(url, {
295
+ method,
296
+ headers: makeHeaders(),
297
+ body: payload ? JSON.stringify(payload) : null
298
+ });
299
+ }
300
+ }
301
+
229
302
  if (!response.ok) {
230
303
  const detail = (await response.text()).trim();
231
304
  throw new Error(`Backend error ${response.status}: ${detail || response.statusText}`);
@@ -238,17 +311,33 @@ export class BackendClient {
238
311
  return JSON.parse(text) as JsonRecord;
239
312
  }
240
313
 
241
- private async *streamSse(path: string, payload: JsonRecord): AsyncGenerator<AgentEvent> {
242
- const headers: Record<string, string> = {"content-type": "application/json"};
243
- if (this.authToken) {
244
- headers.authorization = `Bearer ${this.authToken}`;
245
- }
246
- const response = await fetch(`${this.baseUrl}${path}`, {
314
+ private async *streamSse(path: string, payload: JsonRecord, signal?: AbortSignal): AsyncGenerator<AgentEvent> {
315
+ const makeHeaders = (): Record<string, string> => {
316
+ const h: Record<string, string> = {"content-type": "application/json"};
317
+ if (this.authToken) h.authorization = `Bearer ${this.authToken}`;
318
+ return h;
319
+ };
320
+ const sig = signal ?? null;
321
+
322
+ let response = await fetch(`${this.baseUrl}${path}`, {
247
323
  method: "POST",
248
- headers,
249
- body: JSON.stringify(payload)
324
+ headers: makeHeaders(),
325
+ body: JSON.stringify(payload),
326
+ signal: sig
250
327
  });
251
328
 
329
+ if (response.status === 401) {
330
+ const refreshed = await this.tryRefreshToken();
331
+ if (refreshed) {
332
+ response = await fetch(`${this.baseUrl}${path}`, {
333
+ method: "POST",
334
+ headers: makeHeaders(),
335
+ body: JSON.stringify(payload),
336
+ signal: sig
337
+ });
338
+ }
339
+ }
340
+
252
341
  if (!response.ok) {
253
342
  const detail = (await response.text()).trim();
254
343
  throw new Error(`Backend error ${response.status}: ${detail || response.statusText}`);
package/src/lib/voice.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {spawn, spawnSync} from "child_process";
2
2
  import {randomUUID} from "crypto";
3
+ import {existsSync, readFileSync, writeFileSync} from "fs";
3
4
  import {basename, join} from "path";
4
5
  import {tmpdir} from "os";
5
6
  import {readFile, rm} from "fs/promises";
@@ -10,6 +11,101 @@ const VOICE_TEXT_LIMIT = 600;
10
11
  const DEFAULT_STT_MODEL = process.env.ASTRA_STT_MODEL?.trim() || "whisper-1";
11
12
  const DEFAULT_CHUNK_SECONDS = Number(process.env.ASTRA_STT_CHUNK_SECONDS ?? "2.5");
12
13
 
14
+ // Module-level device cache — resolved once per process lifetime.
15
+ // undefined = not yet resolved; null = not configured; string = resolved device (e.g. ":1")
16
+ let _cachedDevice: string | null | undefined = undefined;
17
+
18
+ /** Read/write the .astra file at the workspace root. */
19
+ const ASTRA_FILE_NAME = ".astra";
20
+
21
+ const readAstraFile = (workspaceRoot: string): Record<string, string> => {
22
+ const filePath = join(workspaceRoot, ASTRA_FILE_NAME);
23
+ if (!existsSync(filePath)) return {};
24
+ const lines = readFileSync(filePath, "utf8").split("\n");
25
+ const result: Record<string, string> = {};
26
+ for (const line of lines) {
27
+ const eq = line.indexOf("=");
28
+ if (eq > 0) result[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
29
+ }
30
+ return result;
31
+ };
32
+
33
+ export const writeAstraKey = (workspaceRoot: string, key: string, value: string): void => {
34
+ const filePath = join(workspaceRoot, ASTRA_FILE_NAME);
35
+ const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
36
+ const lines = existing.split("\n").filter(l => l.trim() && !l.startsWith(`${key}=`));
37
+ lines.push(`${key}=${value}`);
38
+ writeFileSync(filePath, lines.join("\n") + "\n");
39
+ };
40
+
41
+ /**
42
+ * Resolve the mic device string for ffmpeg (e.g. ":1").
43
+ * Priority: in-process cache → .astra file → backend cli_settings → null (needs onboarding).
44
+ * Writes the value to .astra when fetched from the backend so future runs are instant.
45
+ */
46
+ export const resolveAudioDevice = async (workspaceRoot: string): Promise<string | null> => {
47
+ if (_cachedDevice !== undefined) return _cachedDevice;
48
+
49
+ // 1. Read local .astra cache
50
+ const local = readAstraFile(workspaceRoot);
51
+ if (local.ASTRA_STT_DEVICE) {
52
+ _cachedDevice = local.ASTRA_STT_DEVICE;
53
+ return _cachedDevice;
54
+ }
55
+
56
+ // 2. Fetch from backend
57
+ const token = loadSession()?.access_token;
58
+ if (token) {
59
+ try {
60
+ const resp = await fetch(`${getBackendUrl()}/api/user/cli-settings`, {
61
+ headers: {Authorization: `Bearer ${token}`}
62
+ });
63
+ if (resp.ok) {
64
+ const data = (await resp.json()) as {cli_settings?: {audio_device_index?: number}};
65
+ const idx = data.cli_settings?.audio_device_index;
66
+ if (typeof idx === "number") {
67
+ const device = `:${idx}`;
68
+ writeAstraKey(workspaceRoot, "ASTRA_STT_DEVICE", device);
69
+ _cachedDevice = device;
70
+ return _cachedDevice;
71
+ }
72
+ }
73
+ } catch {
74
+ // Silently fall through to onboarding
75
+ }
76
+ }
77
+
78
+ _cachedDevice = null;
79
+ return null;
80
+ };
81
+
82
+ /** Call after onboarding saves a new device so the running process uses it immediately. */
83
+ export const setAudioDevice = (device: string): void => {
84
+ _cachedDevice = device;
85
+ };
86
+
87
+ /** List AVFoundation audio devices. Returns [] on non-macOS or if ffmpeg is missing. */
88
+ export const listAvfAudioDevices = (): Array<{index: number; name: string}> => {
89
+ if (process.platform !== "darwin") return [];
90
+ const result = spawnSync(
91
+ "ffmpeg",
92
+ ["-f", "avfoundation", "-list_devices", "true", "-i", ""],
93
+ {encoding: "utf8", timeout: 8000}
94
+ );
95
+ const output = (result.stderr ?? "") + (result.stdout ?? "");
96
+ const devices: Array<{index: number; name: string}> = [];
97
+ let inAudio = false;
98
+ for (const line of output.split("\n")) {
99
+ if (line.includes("AVFoundation audio devices")) { inAudio = true; continue; }
100
+ if (inAudio) {
101
+ const m = line.match(/\[(\d+)\]\s+(.+)$/);
102
+ if (m?.[1] && m[2]) devices.push({index: parseInt(m[1], 10), name: m[2].trim()});
103
+ else if (devices.length) break;
104
+ }
105
+ }
106
+ return devices;
107
+ };
108
+
13
109
  const safeText = (text: string): string => text.replace(/\s+/g, " ").trim().slice(0, VOICE_TEXT_LIMIT);
14
110
 
15
111
  const commandExists = (binary: string): boolean => {
@@ -39,7 +135,7 @@ const captureAudioChunk = async (seconds: number): Promise<string> => {
39
135
  if (process.platform === "darwin") {
40
136
  // Prefer ffmpeg on macOS (works on Apple Silicon/Homebrew setups).
41
137
  if (commandExists("ffmpeg")) {
42
- const micDevice = process.env.ASTRA_STT_DEVICE?.trim() || ":0";
138
+ const micDevice = _cachedDevice ?? process.env.ASTRA_STT_DEVICE?.trim() ?? ":0";
43
139
  cmd = `ffmpeg -hide_banner -loglevel error -f avfoundation -i "${micDevice}" -ar 16000 -ac 1 -t ${seconds} "${outPath}"`;
44
140
  } else if (commandExists("rec")) {
45
141
  cmd = `rec -q -r 16000 -c 1 "${outPath}" trim 0 ${seconds}`;
@@ -5,6 +5,7 @@ export type AuthSession = {
5
5
  role?: string;
6
6
  display_name?: string;
7
7
  access_token?: string;
8
+ refresh_token?: string;
8
9
  [key: string]: unknown;
9
10
  };
10
11