@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.
- package/.env.example +6 -1
- package/README.md +13 -2
- package/dist/app/App.js +366 -51
- package/dist/app/App.js.map +1 -1
- package/dist/lib/backendClient.js +39 -2
- package/dist/lib/backendClient.js.map +1 -1
- package/dist/lib/config.js +10 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/voice.js +48 -11
- package/dist/lib/voice.js.map +1 -1
- package/package.json +1 -1
- package/src/app/App.tsx +429 -53
- package/src/lib/backendClient.ts +41 -2
- package/src/lib/config.ts +12 -1
- package/src/lib/voice.ts +51 -15
- package/src/types/events.ts +11 -2
package/src/lib/backendClient.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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(`
|
|
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
|
-
|
|
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});
|
package/src/types/events.ts
CHANGED
|
@@ -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};
|