@astra-code/astra-ai 0.1.0 → 0.1.2

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,6 +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
11
 
11
12
  type JsonRecord = Record<string, unknown>;
12
13
 
@@ -20,9 +21,15 @@ export type SessionSummary = {
20
21
 
21
22
  export class BackendClient {
22
23
  private readonly baseUrl: string;
24
+ private authToken: string | null;
23
25
 
24
26
  public constructor(baseUrl = getBackendUrl()) {
25
27
  this.baseUrl = baseUrl;
28
+ this.authToken = loadSession()?.access_token?.trim() || null;
29
+ }
30
+
31
+ public setAuthSession(session: AuthSession | null): void {
32
+ this.authToken = session?.access_token?.trim() || null;
26
33
  }
27
34
 
28
35
  public async get(path: string, params?: Record<string, string>): Promise<JsonRecord> {
@@ -68,6 +75,30 @@ export class BackendClient {
68
75
  }
69
76
  }
70
77
 
78
+ public async getUserProfile(session: AuthSession): Promise<AuthSession> {
79
+ const data = await this.get("/api/user/profile", {
80
+ user_id: session.user_id,
81
+ org_id: session.org_id
82
+ });
83
+ const profile = data as Record<string, unknown>;
84
+ const merged: AuthSession = {
85
+ ...session,
86
+ };
87
+ const email = typeof profile.email === "string" && profile.email ? profile.email : session.email;
88
+ const role = typeof profile.role === "string" ? profile.role : session.role;
89
+ const displayName = typeof profile.display_name === "string" ? profile.display_name : session.display_name;
90
+ if (email) {
91
+ merged.email = email;
92
+ }
93
+ if (role) {
94
+ merged.role = role;
95
+ }
96
+ if (displayName) {
97
+ merged.display_name = displayName;
98
+ }
99
+ return merged;
100
+ }
101
+
71
102
  public async ensureSessionId(
72
103
  user: AuthSession,
73
104
  existingSessionId: string | null,
@@ -185,9 +216,13 @@ export class BackendClient {
185
216
  }
186
217
 
187
218
  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
+ }
188
223
  const response = await fetch(url, {
189
224
  method,
190
- headers: {"content-type": "application/json"},
225
+ headers,
191
226
  body: payload ? JSON.stringify(payload) : null
192
227
  });
193
228
 
@@ -204,9 +239,13 @@ export class BackendClient {
204
239
  }
205
240
 
206
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
+ }
207
246
  const response = await fetch(`${this.baseUrl}${path}`, {
208
247
  method: "POST",
209
- headers: {"content-type": "application/json"},
248
+ headers,
210
249
  body: JSON.stringify(payload)
211
250
  });
212
251
 
package/src/lib/config.ts CHANGED
@@ -2,7 +2,18 @@ import path from "node:path";
2
2
  import os from "node:os";
3
3
  import dotenv from "dotenv";
4
4
 
5
- dotenv.config({path: path.resolve(process.cwd(), ".env"), quiet: true});
5
+ const USER_CONFIG_DIR = path.join(os.homedir(), ".astra-code");
6
+ const USER_ENV_FILE = path.join(USER_CONFIG_DIR, ".env");
7
+ const PROJECT_ENV_FILE = path.resolve(process.cwd(), ".env");
8
+
9
+ // 1) Load user-level defaults from ~/.astra-code/.env
10
+ dotenv.config({path: USER_ENV_FILE, quiet: true});
11
+
12
+ // 2) Optionally load project-level .env (opt-in for trusted repos only)
13
+ const allowProjectEnv = /^(1|true|yes|on)$/i.test(process.env.ASTRA_ALLOW_PROJECT_ENV ?? "");
14
+ if (allowProjectEnv) {
15
+ dotenv.config({path: PROJECT_ENV_FILE, quiet: true});
16
+ }
6
17
 
7
18
  export const APP_NAME = "astra-code";
8
19
  export const SESSION_DIR = path.join(os.homedir(), ".astra-code");
package/src/lib/voice.ts CHANGED
@@ -3,6 +3,8 @@ import {randomUUID} from "crypto";
3
3
  import {basename, join} from "path";
4
4
  import {tmpdir} from "os";
5
5
  import {readFile, rm} from "fs/promises";
6
+ import {getBackendUrl} from "./config.js";
7
+ import {loadSession} from "./sessionStore.js";
6
8
 
7
9
  const VOICE_TEXT_LIMIT = 600;
8
10
  const DEFAULT_STT_MODEL = process.env.ASTRA_STT_MODEL?.trim() || "whisper-1";
@@ -10,6 +12,11 @@ const DEFAULT_CHUNK_SECONDS = Number(process.env.ASTRA_STT_CHUNK_SECONDS ?? "2.5
10
12
 
11
13
  const safeText = (text: string): string => text.replace(/\s+/g, " ").trim().slice(0, VOICE_TEXT_LIMIT);
12
14
 
15
+ const commandExists = (binary: string): boolean => {
16
+ const probe = spawnSync("bash", ["-lc", `command -v ${binary}`], {stdio: "ignore"});
17
+ return probe.status === 0;
18
+ };
19
+
13
20
  const runShell = async (command: string): Promise<void> =>
14
21
  new Promise((resolve, reject) => {
15
22
  const child = spawn(command, {shell: true, stdio: "ignore"});
@@ -30,9 +37,24 @@ const captureAudioChunk = async (seconds: number): Promise<string> => {
30
37
  let cmd = custom ?? "";
31
38
  if (!cmd) {
32
39
  if (process.platform === "darwin") {
33
- cmd = `sox -q -d -r 16000 -c 1 -b 16 "${outPath}" trim 0 ${seconds}`;
40
+ // Prefer ffmpeg on macOS (works on Apple Silicon/Homebrew setups).
41
+ if (commandExists("ffmpeg")) {
42
+ cmd = `ffmpeg -hide_banner -loglevel error -f avfoundation -i ":0" -ar 16000 -ac 1 -t ${seconds} "${outPath}"`;
43
+ } else if (commandExists("rec")) {
44
+ cmd = `rec -q -r 16000 -c 1 "${outPath}" trim 0 ${seconds}`;
45
+ } else {
46
+ throw new Error(
47
+ "No macOS recorder found. Install ffmpeg (recommended) or sox (`rec`), or set ASTRA_STT_CAPTURE_COMMAND."
48
+ );
49
+ }
34
50
  } else if (process.platform === "linux") {
35
- cmd = `arecord -q -f S16_LE -r 16000 -c 1 -d ${Math.ceil(seconds)} "${outPath}"`;
51
+ if (commandExists("arecord")) {
52
+ cmd = `arecord -q -f S16_LE -r 16000 -c 1 -d ${Math.ceil(seconds)} "${outPath}"`;
53
+ } else if (commandExists("ffmpeg")) {
54
+ cmd = `ffmpeg -hide_banner -loglevel error -f pulse -i default -ar 16000 -ac 1 -t ${seconds} "${outPath}"`;
55
+ } else {
56
+ throw new Error("No Linux recorder found. Install arecord/alsa-utils or ffmpeg, or set ASTRA_STT_CAPTURE_COMMAND.");
57
+ }
36
58
  } else {
37
59
  throw new Error("No default audio capture command for this platform. Set ASTRA_STT_CAPTURE_COMMAND.");
38
60
  }
@@ -45,29 +67,26 @@ const captureAudioChunk = async (seconds: number): Promise<string> => {
45
67
  };
46
68
 
47
69
  const transcribeAudioFile = async (filePath: string): Promise<string> => {
48
- const apiKey = process.env.ASTRA_OPENAI_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim();
49
- if (!apiKey) {
50
- throw new Error("Missing OPENAI_API_KEY (or ASTRA_OPENAI_API_KEY) for Whisper STT.");
51
- }
52
-
53
70
  const bytes = await readFile(filePath);
54
71
  const file = new File([bytes], basename(filePath), {type: "audio/wav"});
72
+
73
+ // Backend proxy only: backend holds provider secrets.
74
+ const token = loadSession()?.access_token;
75
+ if (!token) {
76
+ throw new Error("Missing auth session for backend STT. Please sign in again.");
77
+ }
55
78
  const form = new FormData();
56
79
  form.append("file", file);
57
80
  form.append("model", DEFAULT_STT_MODEL);
58
- form.append("response_format", "json");
59
-
60
- const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
81
+ const response = await fetch(`${getBackendUrl()}/api/agent/transcribe`, {
61
82
  method: "POST",
62
- headers: {Authorization: `Bearer ${apiKey}`},
83
+ headers: {Authorization: `Bearer ${token}`},
63
84
  body: form
64
85
  });
65
-
66
86
  if (!response.ok) {
67
87
  const detail = (await response.text()).slice(0, 400);
68
- throw new Error(`Whisper STT failed ${response.status}: ${detail}`);
88
+ throw new Error(`Backend transcription failed ${response.status}: ${detail}`);
69
89
  }
70
-
71
90
  const data = (await response.json()) as {text?: string};
72
91
  return String(data.text ?? "").trim();
73
92
  };
@@ -135,6 +154,7 @@ export const startLiveTranscription = (handlers: {
135
154
  let running = true;
136
155
  let stopping = false;
137
156
  let transcript = "";
157
+ let consecutiveErrors = 0;
138
158
  let finishedResolve: (() => void) | null = null;
139
159
  const finished = new Promise<void>((resolve) => {
140
160
  finishedResolve = resolve;
@@ -146,12 +166,28 @@ export const startLiveTranscription = (handlers: {
146
166
  try {
147
167
  audioPath = await captureAudioChunk(DEFAULT_CHUNK_SECONDS);
148
168
  const piece = await transcribeAudioFile(audioPath);
169
+ consecutiveErrors = 0;
149
170
  if (piece) {
150
171
  transcript = transcript ? `${transcript} ${piece}` : piece;
151
172
  handlers.onPartial(transcript);
152
173
  }
153
174
  } catch (error) {
154
- handlers.onError(error instanceof Error ? error : new Error(String(error)));
175
+ consecutiveErrors += 1;
176
+ const err = error instanceof Error ? error : new Error(String(error));
177
+ if (/Command failed \(127\)/.test(err.message)) {
178
+ handlers.onError(
179
+ new Error(
180
+ "Audio capture tool not found. Install ffmpeg/rec (macOS) or arecord/ffmpeg (Linux), or set ASTRA_STT_CAPTURE_COMMAND."
181
+ )
182
+ );
183
+ running = false;
184
+ } else {
185
+ handlers.onError(err);
186
+ // Stop error spam on persistent capture/transcription failures.
187
+ if (consecutiveErrors >= 2) {
188
+ running = false;
189
+ }
190
+ }
155
191
  } finally {
156
192
  if (audioPath) {
157
193
  await rm(audioPath, {force: true});
@@ -2,6 +2,9 @@ export type AuthSession = {
2
2
  user_id: string;
3
3
  org_id: string;
4
4
  email?: string;
5
+ role?: string;
6
+ display_name?: string;
7
+ access_token?: string;
5
8
  [key: string]: unknown;
6
9
  };
7
10
 
@@ -13,14 +16,19 @@ export type ChatMessage = {
13
16
  export type AgentEvent =
14
17
  | {type: "text"; content?: string}
15
18
  | {type: "thinking"; content?: string}
16
- | {type: "tool_start"; tool?: {name?: string}}
19
+ | {type: "tool_start"; tool?: {name?: string; id?: string; arguments?: Record<string, unknown>}}
20
+ | {type: "file_edit_start"; tool_id?: string; tool_name?: string; data?: {tool_id?: string; tool_name?: string}}
17
21
  | {
18
22
  type: "tool_result";
19
23
  success?: boolean;
20
24
  tool_name?: string;
25
+ result_type?: string;
26
+ tool_call_id?: string;
21
27
  data?: Record<string, unknown>;
22
28
  error?: string;
23
29
  }
30
+ | {type: "file_edit_path"; tool_id?: string; path?: string; is_create?: boolean; tool_name?: string; data?: {tool_id?: string; path?: string; is_create?: boolean; tool_name?: string}}
31
+ | {type: "file_edit_delta"; tool_id?: string; content?: string; data?: {tool_id?: string}}
24
32
  | {
25
33
  type: "run_in_terminal";
26
34
  terminal_id?: string;
@@ -30,4 +38,5 @@ export type AgentEvent =
30
38
  }
31
39
  | {type: "credits_update"; remaining?: number; cost?: number}
32
40
  | {type: "credits_exhausted"; message?: string}
33
- | {type: "error"; error?: string; content?: string};
41
+ | {type: "error"; error?: string; content?: string}
42
+ | {type: string; [key: string]: unknown};