praixis 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Michael E. Ramirez A.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # Praixis Engine — Node.js Client
2
+
3
+ A lightweight, **zero-dependency** Node.js client for the Praixis Engine API.
4
+ It is built on the global `fetch` (Node 18+), so an upstream package release
5
+ can never break it.
6
+
7
+ - Promise-based, async/await API
8
+ - No runtime dependencies
9
+ - Ships hand-authored TypeScript declarations (`index.d.ts`) — no build step
10
+ - Resource-grouped: `client.chat`, `client.rag`
11
+
12
+ > The companion Python client lives in its own repository.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install praixis
18
+ ```
19
+
20
+ Requires Node.js 18+. The package is ESM (`"type": "module"`).
21
+
22
+ ## Authentication
23
+
24
+ Every request authenticates with your app API key, sent as the `X-API-Key`
25
+ header. The server's admin panel (`/api/system/*`, HTTP Basic) is intentionally
26
+ not exposed by this client — admin tasks belong in the browser UI, and embedding
27
+ admin credentials in app code is an anti-pattern.
28
+
29
+ ```js
30
+ import { PraixisClient } from "praixis";
31
+
32
+ const client = new PraixisClient("http://localhost:8080", "your-api-key", {
33
+ timeoutMs: 30000, // optional, default 30s
34
+ });
35
+ ```
36
+
37
+ ## Chat
38
+
39
+ ```js
40
+ // Start a conversation
41
+ const reply = await client.chat.send("Hello, world!");
42
+ console.log(reply.session_id, reply.response);
43
+
44
+ // Continue it
45
+ await client.chat.send("And again?", { sessionId: reply.session_id });
46
+
47
+ // JSON-mode response, custom system prompt
48
+ await client.chat.send("List 3 colors", { responseFormat: "json", systemPrompt: "Be terse" });
49
+
50
+ // Sessions
51
+ await client.chat.listSessions(); // -> [sessionId, ...]
52
+ await client.chat.getHistory(sessionId); // -> { session_id, history: [...] }
53
+ await client.chat.clearHistory(sessionId);
54
+
55
+ // Summarize an uploaded file ({ filename, content[, contentType] } or a Blob/File)
56
+ await client.chat.summarizeFile({ filename: "notes.txt", content: "raw text here" });
57
+ ```
58
+
59
+ ### Streaming
60
+
61
+ The server streams chat, RAG answers, and file summaries as `text/event-stream`.
62
+ The buffered methods above (`send`, `ask`, `summarizeFile`) collect the whole
63
+ response and return it decoded — the right default for scripts and backends.
64
+
65
+ For token-by-token output, use the streaming variants, which return an async
66
+ iterator of events. Marker events (`session_id`, `search_query`, `sources`,
67
+ `file`, `progress`, `error`) arrive before the `token` events that carry content:
68
+
69
+ ```js
70
+ for await (const event of client.chat.stream("Tell me a story")) {
71
+ if (event.type === "token") process.stdout.write(event.value);
72
+ else if (event.type === "session_id") console.log("session:", event.value);
73
+ }
74
+
75
+ // RAG: client.rag.askStream(question, { collectionName })
76
+ // -> session_id, search_query, sources, then token events
77
+ // File summary: client.chat.summarizeFileStream(file)
78
+ // -> file, [progress...], then token events
79
+ ```
80
+
81
+ ## RAG
82
+
83
+ ```js
84
+ // Ingest one or many documents into a collection
85
+ await client.rag.upload({ filename: "manual.txt", content: "..." }, { collectionName: "docs" });
86
+ await client.rag.upload(
87
+ [
88
+ { filename: "a.txt", content: "..." },
89
+ { filename: "b.txt", content: "..." },
90
+ ],
91
+ { collectionName: "docs" },
92
+ );
93
+
94
+ // Ask a question grounded in a collection
95
+ const ans = await client.rag.ask("What does the manual say about setup?", { collectionName: "docs" });
96
+ console.log(ans.answer, ans.sources);
97
+
98
+ // Embeddings, listing, deletion, compare, summarize
99
+ await client.rag.embed("some text");
100
+ await client.rag.listCollections();
101
+ await client.rag.listFiles("docs");
102
+ await client.rag.deleteFile("docs", "a.txt");
103
+ await client.rag.deleteCollection("docs");
104
+ await client.rag.compare("docs", "a.txt", "b.txt");
105
+ await client.rag.summarizeDocument("docs", "manual.txt");
106
+ ```
107
+
108
+ ## Error handling
109
+
110
+ ```js
111
+ import { APIError, AuthenticationError, NotFoundError, RateLimitError, APIConnectionError } from "praixis";
112
+
113
+ try {
114
+ await client.chat.send("hi");
115
+ } catch (err) {
116
+ if (err instanceof AuthenticationError) { /* 401 / 403 */ }
117
+ else if (err instanceof NotFoundError) { /* 404 */ }
118
+ else if (err instanceof RateLimitError) { /* 429 */ }
119
+ else if (err instanceof APIError) { console.log(err.statusCode, err.detail); }
120
+ else if (err instanceof APIConnectionError) { /* never reached the server */ }
121
+ }
122
+ ```
123
+
124
+ All errors inherit from `PraixisError`.
125
+
126
+ ## Testing
127
+
128
+ The suite runs against a standard-library mock HTTP server — no network, no
129
+ dependencies:
130
+
131
+ ```bash
132
+ npm test # node --test
133
+ ```
134
+
135
+ ## Privacy note
136
+
137
+ This client transmits whatever you pass to it (prompts, documents, session IDs)
138
+ to the configured Praixis Engine server. Those payloads may contain personal
139
+ data — handle them according to your own privacy obligations. The client stores
140
+ nothing locally and adds no telemetry.
141
+
142
+ ## License
143
+
144
+ MIT — see [LICENSE](./LICENSE).
package/index.d.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Type declarations for the Praixis Engine Node.js client.
3
+ * Hand-authored - the runtime is plain JavaScript with zero dependencies.
4
+ *
5
+ * Only confirmed response shapes are typed; loosely-defined server responses
6
+ * are returned as `Record<string, unknown>` so extra fields are never lost.
7
+ */
8
+
9
+ export type ResponseFormat = "text" | "json";
10
+ export type ChunkingStrategy = "semantic" | "character";
11
+
12
+ /**
13
+ * An event yielded by the streaming methods (`chat.stream`, `rag.askStream`,
14
+ * `chat.summarizeFileStream`). Markers arrive before `token` events.
15
+ */
16
+ export type StreamEvent =
17
+ | { type: "session_id"; value: string }
18
+ | { type: "search_query"; value: string }
19
+ | { type: "sources"; value: string[] }
20
+ | { type: "file"; value: string }
21
+ | { type: "progress"; value: string }
22
+ | { type: "error"; value: string }
23
+ | { type: "token"; value: string };
24
+
25
+ export interface ChatMessage {
26
+ role: string;
27
+ content: string;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export interface ChatResponse {
32
+ /** From the stream's [SESSION_ID:...] marker; null if absent. */
33
+ session_id: string | null;
34
+ /** Assembled reply text for "text"; parsed JSON for "json" (raw text if it failed to parse). */
35
+ response: unknown;
36
+ response_format: string;
37
+ }
38
+
39
+ export interface SummaryResponse {
40
+ /** From the stream's [FILE:...] marker; null if absent. */
41
+ filename: string | null;
42
+ /** Assembled summary text. */
43
+ summary: string;
44
+ /** Present only if the stream emitted an [ERROR:...] marker. */
45
+ error?: string;
46
+ }
47
+
48
+ export interface SessionHistory {
49
+ session_id: string;
50
+ history: ChatMessage[];
51
+ }
52
+
53
+ export interface StatusMessage {
54
+ status: string;
55
+ message: string;
56
+ }
57
+
58
+ /** Returned by clearHistory - the server uses `detail` here, not `message`. */
59
+ export interface SessionDeleted {
60
+ status: string;
61
+ detail: string;
62
+ }
63
+
64
+ /** One per-file outcome from a multi-file upload. */
65
+ export interface UploadResult {
66
+ filename: string | null;
67
+ status: "success" | "error";
68
+ detail?: string;
69
+ }
70
+
71
+ export interface UploadResponse {
72
+ collection_name: string;
73
+ processed: number;
74
+ succeeded: number;
75
+ results: UploadResult[];
76
+ }
77
+
78
+ export interface AskResponse {
79
+ answer: string;
80
+ /** Source filenames from the stream's [SOURCES:...] marker. */
81
+ sources: string[];
82
+ /** The (possibly reformulated) query from the [SEARCH_QUERY:...] marker. */
83
+ search_query: string | null;
84
+ session_id: string | null;
85
+ }
86
+
87
+ export type FileInput =
88
+ | { filename: string; content: string | Uint8Array | Blob; contentType?: string }
89
+ | Blob;
90
+
91
+ export interface ClientOptions {
92
+ timeoutMs?: number;
93
+ }
94
+
95
+ export interface ChatOptions {
96
+ systemPrompt?: string;
97
+ sessionId?: string;
98
+ responseFormat?: ResponseFormat;
99
+ }
100
+
101
+ export interface SummarizeFileOptions {
102
+ task?: string;
103
+ tone?: string;
104
+ }
105
+
106
+ export interface UploadOptions {
107
+ collectionName?: string;
108
+ chunkSize?: number;
109
+ chunkOverlap?: number;
110
+ chunkingStrategy?: ChunkingStrategy;
111
+ }
112
+
113
+ export interface AskOptions {
114
+ collectionName: string;
115
+ sessionId?: string;
116
+ nResults?: number;
117
+ systemPrompt?: string;
118
+ metadataFilter?: Record<string, unknown>;
119
+ }
120
+
121
+ type Dict = Record<string, unknown>;
122
+
123
+ export class ChatResource {
124
+ send(prompt: string, opts?: ChatOptions): Promise<ChatResponse>;
125
+ stream(prompt: string, opts?: ChatOptions): AsyncGenerator<StreamEvent>;
126
+ summarizeFile(file: FileInput, opts?: SummarizeFileOptions): Promise<SummaryResponse>;
127
+ summarizeFileStream(file: FileInput, opts?: SummarizeFileOptions): AsyncGenerator<StreamEvent>;
128
+ listSessions(): Promise<string[]>;
129
+ getHistory(sessionId: string): Promise<SessionHistory>;
130
+ clearHistory(sessionId: string): Promise<SessionDeleted>;
131
+ }
132
+
133
+ export class RagResource {
134
+ upload(files: FileInput | FileInput[], opts?: UploadOptions): Promise<UploadResponse>;
135
+ ask(question: string, opts: AskOptions): Promise<AskResponse>;
136
+ askStream(question: string, opts: AskOptions): AsyncGenerator<StreamEvent>;
137
+ embed(text: string): Promise<Dict>;
138
+ listCollections(): Promise<unknown[]>;
139
+ listFiles(collectionName: string): Promise<Dict>;
140
+ deleteCollection(collectionName: string): Promise<StatusMessage>;
141
+ deleteFile(collectionName: string, filename: string): Promise<StatusMessage>;
142
+ compare(collectionName: string, file1: string, file2: string): Promise<Dict>;
143
+ summarizeDocument(collectionName: string, filename: string): Promise<Dict>;
144
+ }
145
+
146
+ export class PraixisClient {
147
+ constructor(baseURL: string, apiKey?: string, opts?: ClientOptions);
148
+ readonly baseURL: string;
149
+ chat: ChatResource;
150
+ rag: RagResource;
151
+ }
152
+
153
+ export class PraixisError extends Error {}
154
+ export class APIConnectionError extends PraixisError {
155
+ cause?: unknown;
156
+ }
157
+ export class APIError extends PraixisError {
158
+ statusCode: number;
159
+ body: string;
160
+ detail: string;
161
+ }
162
+ export class AuthenticationError extends APIError {}
163
+ export class NotFoundError extends APIError {}
164
+ export class RateLimitError extends APIError {}
package/index.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Praixis Engine Node.js client.
3
+ *
4
+ * A zero-dependency client built on the global `fetch` (Node 18+), so upstream
5
+ * package releases can never break it.
6
+ *
7
+ * import { PraixisClient } from "praixis";
8
+ *
9
+ * const client = new PraixisClient("http://localhost:8080", "my-api-key");
10
+ * const reply = await client.chat.send("Hello");
11
+ * console.log(reply.response);
12
+ */
13
+
14
+ export { PraixisClient } from "./src/client.js";
15
+ export {
16
+ PraixisError,
17
+ APIError,
18
+ APIConnectionError,
19
+ AuthenticationError,
20
+ NotFoundError,
21
+ RateLimitError,
22
+ } from "./src/errors.js";
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "praixis",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency Node.js client for the Praixis Engine API",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "index.d.ts",
17
+ "src"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test"
24
+ },
25
+ "keywords": ["praixis", "llm", "rag", "ai"],
26
+ "author": "Michael E. Ramirez A.",
27
+ "license": "MIT",
28
+ "dependencies": {}
29
+ }
package/src/client.js ADDED
@@ -0,0 +1,22 @@
1
+ /** The top-level Praixis Engine client. */
2
+
3
+ import { Transport } from "./transport.js";
4
+ import { ChatResource } from "./resources/chat.js";
5
+ import { RagResource } from "./resources/rag.js";
6
+
7
+ export class PraixisClient {
8
+ /**
9
+ * @param {string} baseURL Root URL, e.g. "http://localhost:8080".
10
+ * @param {string} [apiKey] Sent as the `X-API-Key` header on every request.
11
+ * @param {{ timeoutMs?: number }} [opts]
12
+ */
13
+ constructor(baseURL, apiKey = "", { timeoutMs = 30000 } = {}) {
14
+ this._transport = new Transport(baseURL, apiKey, { timeoutMs });
15
+ this.chat = new ChatResource(this._transport);
16
+ this.rag = new RagResource(this._transport);
17
+ }
18
+
19
+ get baseURL() {
20
+ return this._transport.baseURL;
21
+ }
22
+ }
package/src/errors.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Error types raised by the Praixis client.
3
+ *
4
+ * The hierarchy is intentionally small so callers can catch broadly
5
+ * (`PraixisError`) or narrowly (`AuthenticationError`) without depending on
6
+ * any third-party error classes.
7
+ */
8
+
9
+ export class PraixisError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = this.constructor.name;
13
+ }
14
+ }
15
+
16
+ /** The request never reached the server (DNS, refused, timeout, TLS). */
17
+ export class APIConnectionError extends PraixisError {
18
+ constructor(message, { cause } = {}) {
19
+ super(message);
20
+ if (cause) this.cause = cause;
21
+ }
22
+ }
23
+
24
+ /** The server returned a non-2xx response. */
25
+ export class APIError extends PraixisError {
26
+ constructor(statusCode, body, detail) {
27
+ super(`API error (status ${statusCode}): ${detail ?? body}`);
28
+ this.statusCode = statusCode;
29
+ this.body = body;
30
+ this.detail = detail ?? body;
31
+ }
32
+ }
33
+
34
+ /** A 401 or 403 response - missing or invalid API key. */
35
+ export class AuthenticationError extends APIError {}
36
+
37
+ /** A 404 response - the requested resource does not exist. */
38
+ export class NotFoundError extends APIError {}
39
+
40
+ /** A 429 response - the per-route rate limit was exceeded. */
41
+ export class RateLimitError extends APIError {}
42
+
43
+ /** Return the most specific APIError subclass for a status code. */
44
+ export function errorForStatus(statusCode, body, detail) {
45
+ if (statusCode === 401 || statusCode === 403) return new AuthenticationError(statusCode, body, detail);
46
+ if (statusCode === 404) return new NotFoundError(statusCode, body, detail);
47
+ if (statusCode === 429) return new RateLimitError(statusCode, body, detail);
48
+ return new APIError(statusCode, body, detail);
49
+ }
package/src/files.js ADDED
@@ -0,0 +1,30 @@
1
+ /** Helpers for turning caller-supplied files into multipart parts. */
2
+
3
+ const DEFAULT_CONTENT_TYPE = "application/octet-stream";
4
+
5
+ /**
6
+ * Normalize one file input into a part for the given field.
7
+ * Accepts:
8
+ * - { filename, content, contentType? }
9
+ * - a Blob/File (its own name is used unless `filename` is on it)
10
+ */
11
+ export function toPart(item, field) {
12
+ if (item && typeof item === "object" && "content" in item) {
13
+ return {
14
+ field,
15
+ filename: item.filename,
16
+ content: item.content,
17
+ contentType: item.contentType ?? DEFAULT_CONTENT_TYPE,
18
+ };
19
+ }
20
+ if (item instanceof Blob) {
21
+ return { field, filename: item.name ?? "file", content: item, contentType: item.type || DEFAULT_CONTENT_TYPE };
22
+ }
23
+ throw new TypeError("file must be { filename, content, contentType? } or a Blob/File");
24
+ }
25
+
26
+ /** Normalize one file or an array of files into parts under `field`. */
27
+ export function toParts(items, field) {
28
+ const list = Array.isArray(items) ? items : [items];
29
+ return list.map((it) => toPart(it, field));
30
+ }
@@ -0,0 +1,97 @@
1
+ /** Core AI endpoints - prefix /general-requests. */
2
+
3
+ import { toPart } from "../files.js";
4
+ import { streamEvents, collectStream } from "../stream.js";
5
+
6
+ const PREFIX = "/general-requests";
7
+ const DEFAULT_TASK = "Summarize the key points of this document.";
8
+ const DEFAULT_TONE = "Professional and objective";
9
+
10
+ export class ChatResource {
11
+ constructor(transport) {
12
+ this._t = transport;
13
+ }
14
+
15
+ _chatBody(prompt, { systemPrompt, sessionId, responseFormat = "text" } = {}) {
16
+ const body = { prompt, response_format: responseFormat };
17
+ if (systemPrompt !== undefined) body.system_prompt = systemPrompt;
18
+ if (sessionId !== undefined) body.session_id = sessionId;
19
+ return body;
20
+ }
21
+
22
+ _summaryArgs(file, { task = DEFAULT_TASK, tone = DEFAULT_TONE } = {}) {
23
+ return {
24
+ files: [toPart(file, "file")],
25
+ fields: [
26
+ { name: "task", value: task },
27
+ { name: "tone", value: tone },
28
+ ],
29
+ };
30
+ }
31
+
32
+ /**
33
+ * POST /general-requests/chat - send a prompt and get the full reply.
34
+ * Omit `sessionId` to start a new conversation. The server streams the reply;
35
+ * this buffers it and returns { session_id, response, response_format }.
36
+ */
37
+ async send(prompt, opts = {}) {
38
+ const { markers, body } = await collectStream(
39
+ this._t.requestStream("POST", `${PREFIX}/chat`, { body: this._chatBody(prompt, opts) }),
40
+ );
41
+ const responseFormat = opts.responseFormat ?? "text";
42
+ let response = body;
43
+ if (responseFormat === "json") {
44
+ try {
45
+ response = JSON.parse(body);
46
+ } catch {
47
+ // server didn't return valid JSON; hand back the raw text
48
+ }
49
+ }
50
+ return { session_id: markers.session_id ?? null, response, response_format: responseFormat };
51
+ }
52
+
53
+ /**
54
+ * POST /general-requests/chat - stream the reply incrementally, yielding
55
+ * `{ type: "session_id" | "token", value }` events as they arrive.
56
+ */
57
+ stream(prompt, opts = {}) {
58
+ return streamEvents(this._t.requestStream("POST", `${PREFIX}/chat`, { body: this._chatBody(prompt, opts) }));
59
+ }
60
+
61
+ /**
62
+ * POST /general-requests/file_summary - summarize one uploaded file.
63
+ * `file` is { filename, content, contentType? } or a Blob/File.
64
+ */
65
+ async summarizeFile(file, opts = {}) {
66
+ const { markers, body } = await collectStream(
67
+ this._t.uploadStream(`${PREFIX}/file_summary`, this._summaryArgs(file, opts)),
68
+ );
69
+ const result = { filename: markers.file ?? null, summary: body };
70
+ if (markers.error !== undefined) result.error = markers.error;
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * POST /general-requests/file_summary - stream the summary incrementally,
76
+ * yielding `{ type: "file" | "progress" | "error" | "token", value }` events.
77
+ */
78
+ summarizeFileStream(file, opts = {}) {
79
+ return streamEvents(this._t.uploadStream(`${PREFIX}/file_summary`, this._summaryArgs(file, opts)));
80
+ }
81
+
82
+ /** GET /general-requests/chat/sessions/active - active session IDs. */
83
+ async listSessions() {
84
+ const data = await this._t.requestJSON("GET", `${PREFIX}/chat/sessions/active`);
85
+ return data?.active_sessions ?? [];
86
+ }
87
+
88
+ /** GET /general-requests/chat/{sessionId} - a session's message history. */
89
+ async getHistory(sessionId) {
90
+ return this._t.requestJSON("GET", `${PREFIX}/chat/${encodeURIComponent(sessionId)}`);
91
+ }
92
+
93
+ /** DELETE /general-requests/chat/{sessionId} - clear a session. */
94
+ async clearHistory(sessionId) {
95
+ return this._t.requestJSON("DELETE", `${PREFIX}/chat/${encodeURIComponent(sessionId)}`);
96
+ }
97
+ }
@@ -0,0 +1,105 @@
1
+ /** Vector / RAG endpoints - prefix /rag-db. */
2
+
3
+ import { toParts } from "../files.js";
4
+ import { streamEvents, collectStream } from "../stream.js";
5
+
6
+ const PREFIX = "/rag-db";
7
+
8
+ export class RagResource {
9
+ constructor(transport) {
10
+ this._t = transport;
11
+ }
12
+
13
+ /**
14
+ * POST /rag-db/upload - ingest one or more documents into a collection.
15
+ * Each file is { filename, content, contentType? } or a Blob/File.
16
+ */
17
+ async upload(files, { collectionName = "main", chunkSize = 2000, chunkOverlap = 150, chunkingStrategy = "semantic" } = {}) {
18
+ return this._t.upload(`${PREFIX}/upload`, {
19
+ files: toParts(files, "files"),
20
+ fields: [
21
+ { name: "collection_name", value: collectionName },
22
+ { name: "chunk_size", value: String(chunkSize) },
23
+ { name: "chunk_overlap", value: String(chunkOverlap) },
24
+ { name: "chunking_strategy", value: chunkingStrategy },
25
+ ],
26
+ });
27
+ }
28
+
29
+ _askBody(question, { collectionName, sessionId, nResults = 5, systemPrompt, metadataFilter } = {}) {
30
+ const body = { collection_name: collectionName, question, n_results: nResults };
31
+ if (sessionId !== undefined) body.session_id = sessionId;
32
+ if (systemPrompt !== undefined) body.system_prompt = systemPrompt;
33
+ if (metadataFilter !== undefined) body.metadata_filter = metadataFilter;
34
+ return body;
35
+ }
36
+
37
+ /**
38
+ * POST /rag-db/ask - answer a question grounded in a collection. The server
39
+ * streams the answer; this buffers it and returns
40
+ * { answer, sources, search_query, session_id }.
41
+ */
42
+ async ask(question, opts = {}) {
43
+ const { markers, body: answer } = await collectStream(
44
+ this._t.requestStream("POST", `${PREFIX}/ask`, { body: this._askBody(question, opts) }),
45
+ );
46
+ return {
47
+ answer,
48
+ sources: markers.sources ?? [],
49
+ search_query: markers.search_query ?? null,
50
+ session_id: markers.session_id ?? null,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * POST /rag-db/ask - stream the grounded answer incrementally, yielding
56
+ * `{ type: "session_id" | "search_query" | "sources" | "token", value }` events.
57
+ */
58
+ askStream(question, opts = {}) {
59
+ return streamEvents(this._t.requestStream("POST", `${PREFIX}/ask`, { body: this._askBody(question, opts) }));
60
+ }
61
+
62
+ /** POST /rag-db/embed - return the embedding vector for `text`. */
63
+ async embed(text) {
64
+ return this._t.requestJSON("POST", `${PREFIX}/embed`, { body: { text } });
65
+ }
66
+
67
+ /** GET /rag-db/list - collections owned by the calling app. */
68
+ async listCollections() {
69
+ const data = await this._t.requestJSON("GET", `${PREFIX}/list`);
70
+ return data?.active_collections ?? [];
71
+ }
72
+
73
+ /** GET /rag-db/{collectionName}/files - files in a collection. */
74
+ async listFiles(collectionName) {
75
+ return this._t.requestJSON("GET", `${PREFIX}/${encodeURIComponent(collectionName)}/files`);
76
+ }
77
+
78
+ /** DELETE /rag-db/delete/{collectionName} - remove an entire collection. */
79
+ async deleteCollection(collectionName) {
80
+ return this._t.requestJSON("DELETE", `${PREFIX}/delete/${encodeURIComponent(collectionName)}`);
81
+ }
82
+
83
+ /** DELETE /rag-db/{collectionName}/files/{filename} - remove one file. */
84
+ async deleteFile(collectionName, filename) {
85
+ return this._t.requestJSON(
86
+ "DELETE",
87
+ `${PREFIX}/${encodeURIComponent(collectionName)}/files/${encodeURIComponent(filename)}`,
88
+ );
89
+ }
90
+
91
+ /** POST /rag-db/knowledge_base/compare - compare two stored documents. */
92
+ async compare(collectionName, file1, file2) {
93
+ return this._t.requestJSON("POST", `${PREFIX}/knowledge_base/compare`, {
94
+ body: { collection_name: collectionName, file_1: file1, file_2: file2 },
95
+ });
96
+ }
97
+
98
+ /** GET /rag-db/knowledge_base/{collectionName}/files/{filename}/summary. */
99
+ async summarizeDocument(collectionName, filename) {
100
+ return this._t.requestJSON(
101
+ "GET",
102
+ `${PREFIX}/knowledge_base/${encodeURIComponent(collectionName)}/files/${encodeURIComponent(filename)}/summary`,
103
+ );
104
+ }
105
+ }
package/src/stream.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Parser for the Praixis Engine's streamed responses.
3
+ *
4
+ * Chat, RAG `ask`, and file-summary endpoints are served as `text/event-stream`
5
+ * bodies that are NOT JSON. They begin with zero or more single-line markers and
6
+ * are followed by the raw generated content:
7
+ *
8
+ * [SESSION_ID:<id>]\n
9
+ * [SEARCH_QUERY:<query>]\n (RAG ask only)
10
+ * [SOURCES:<a.txt,b.txt>]\n (RAG ask only)
11
+ * [FILE:<filename>]\n (file summary only)
12
+ * [PROGRESS:<message>]\n (file summary, large docs; may repeat)
13
+ * [ERROR:<message>]\n (in-stream failure; always before content)
14
+ * ...content tokens...
15
+ *
16
+ * Markers are emitted on their own `\n`-terminated lines before any content, so
17
+ * we peel complete marker lines off the head of the stream and treat everything
18
+ * from the first non-marker byte onward as content.
19
+ */
20
+
21
+ const MARKER_KEYS = ["SESSION_ID", "SEARCH_QUERY", "SOURCES", "FILE", "PROGRESS", "ERROR"];
22
+
23
+ /** A complete leading marker line: `[KEY:value]\n`. */
24
+ const MARKER_RE = new RegExp(`^\\[(${MARKER_KEYS.join("|")}):([^\\n]*)\\]\\n`);
25
+
26
+ /** A buffer that is still a possible (incomplete) marker line: no `\n` yet. */
27
+ const PARTIAL_MARKER_RE = /^\[[A-Z_]*(:[^\n]*)?$/;
28
+
29
+ /** Server marker key -> public event type. */
30
+ const EVENT_TYPE = {
31
+ SESSION_ID: "session_id",
32
+ SEARCH_QUERY: "search_query",
33
+ SOURCES: "sources",
34
+ FILE: "file",
35
+ PROGRESS: "progress",
36
+ ERROR: "error",
37
+ };
38
+
39
+ function markerEvent(key, value) {
40
+ if (key === "SOURCES") return { type: "sources", value: value ? value.split(",") : [] };
41
+ return { type: EVENT_TYPE[key], value };
42
+ }
43
+
44
+ /**
45
+ * Turn an async iterable of decoded text chunks into an async iterable of events:
46
+ *
47
+ * { type: "session_id" | "search_query" | "file" | "progress" | "error", value: string }
48
+ * { type: "sources", value: string[] }
49
+ * { type: "token", value: string } // a piece of the generated content
50
+ *
51
+ * @param {AsyncIterable<string>} chunks
52
+ * @returns {AsyncGenerator<{ type: string, value: string | string[] }>}
53
+ */
54
+ export async function* streamEvents(chunks) {
55
+ let buffer = "";
56
+ let inContent = false;
57
+
58
+ const drainMarkers = function* () {
59
+ let m;
60
+ while ((m = MARKER_RE.exec(buffer))) {
61
+ yield markerEvent(m[1], m[2]);
62
+ buffer = buffer.slice(m[0].length);
63
+ }
64
+ };
65
+
66
+ for await (const chunk of chunks) {
67
+ if (inContent) {
68
+ if (chunk) yield { type: "token", value: chunk };
69
+ continue;
70
+ }
71
+ buffer += chunk;
72
+ yield* drainMarkers();
73
+ // The buffer no longer starts with a complete marker. If it can't still grow
74
+ // into one either, the marker section is over: the rest is content.
75
+ if (buffer && !PARTIAL_MARKER_RE.test(buffer)) {
76
+ yield { type: "token", value: buffer };
77
+ buffer = "";
78
+ inContent = true;
79
+ }
80
+ }
81
+
82
+ // End of stream: peel any trailing complete markers, emit the remainder.
83
+ yield* drainMarkers();
84
+ if (buffer) yield { type: "token", value: buffer };
85
+ }
86
+
87
+ /**
88
+ * Drain a {@link streamEvents} iterable into the buffered shape used by the
89
+ * non-streaming methods: `{ markers, body }`, where `markers` maps event type ->
90
+ * value (`progress` collects into an array) and `body` is the joined content.
91
+ *
92
+ * @param {AsyncIterable<string>} chunks
93
+ * @returns {Promise<{ markers: Record<string, string | string[]>, body: string }>}
94
+ */
95
+ export async function collectStream(chunks) {
96
+ const markers = {};
97
+ let body = "";
98
+ for await (const ev of streamEvents(chunks)) {
99
+ if (ev.type === "token") {
100
+ body += ev.value;
101
+ } else if (ev.type === "progress") {
102
+ (markers.progress ??= []).push(ev.value);
103
+ } else {
104
+ markers[ev.type] = ev.value;
105
+ }
106
+ }
107
+ return { markers, body };
108
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Low-level HTTP transport built on the global `fetch` (Node 18+).
3
+ *
4
+ * Depends on nothing outside the Node runtime, so the client cannot be broken by
5
+ * an upstream package release. Every request authenticates with the app-level
6
+ * `X-API-Key` header; admin (`/api/system`) endpoints are intentionally not
7
+ * exposed by this SDK.
8
+ */
9
+
10
+ import { APIConnectionError, errorForStatus } from "./errors.js";
11
+
12
+ export class Transport {
13
+ constructor(baseURL, apiKey = "", { timeoutMs = 30000 } = {}) {
14
+ this.baseURL = baseURL.replace(/\/+$/, "");
15
+ this.apiKey = apiKey;
16
+ this.timeoutMs = timeoutMs;
17
+ }
18
+
19
+ _url(path, params) {
20
+ const url = new URL(this.baseURL + path);
21
+ if (params) {
22
+ for (const [k, v] of Object.entries(params)) {
23
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
24
+ }
25
+ }
26
+ return url.toString();
27
+ }
28
+
29
+ _authHeader() {
30
+ return this.apiKey ? { "X-API-Key": this.apiKey } : {};
31
+ }
32
+
33
+ async _raise(resp) {
34
+ const body = await resp.text().catch(() => "");
35
+ let detail;
36
+ try {
37
+ const parsed = JSON.parse(body);
38
+ if (parsed && typeof parsed === "object" && "detail" in parsed) {
39
+ // FastAPI validation errors surface `detail` as a list/object; keep it
40
+ // readable instead of stringifying to "[object Object]".
41
+ detail = typeof parsed.detail === "string" ? parsed.detail : JSON.stringify(parsed.detail);
42
+ }
43
+ } catch {
44
+ // not JSON; leave detail undefined
45
+ }
46
+ return errorForStatus(resp.status, body, detail);
47
+ }
48
+
49
+ /** Perform the fetch with a timeout and raise on a non-2xx status. */
50
+ async _fetch(url, init) {
51
+ const controller = new AbortController();
52
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
53
+ let resp;
54
+ try {
55
+ resp = await fetch(url, { ...init, signal: controller.signal });
56
+ } catch (err) {
57
+ const reason = err?.name === "AbortError" ? "request timed out" : err?.message;
58
+ throw new APIConnectionError(`failed to reach ${this.baseURL}: ${reason}`, { cause: err });
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ if (!resp.ok) throw await this._raise(resp);
63
+ return resp;
64
+ }
65
+
66
+ /** Build the fetch init for a (possibly JSON-bodied) request. */
67
+ _jsonInit(method, body) {
68
+ const headers = this._authHeader();
69
+ let payload;
70
+ if (body !== undefined) {
71
+ headers["Content-Type"] = "application/json";
72
+ payload = JSON.stringify(body);
73
+ }
74
+ return { method, headers, body: payload };
75
+ }
76
+
77
+ /** Build the fetch init for a multipart/form-data POST (fetch sets the boundary). */
78
+ _formInit(files, fields) {
79
+ const form = new FormData();
80
+ for (const { name, value } of fields) form.append(name, value);
81
+ for (const { field, filename, content, contentType } of files) {
82
+ const blob = content instanceof Blob ? content : new Blob([content], { type: contentType });
83
+ form.append(field, blob, filename);
84
+ }
85
+ return { method: "POST", headers: this._authHeader(), body: form };
86
+ }
87
+
88
+ /** Read a response body as JSON (or null for an empty body). */
89
+ async _readJSON(resp) {
90
+ const text = await resp.text();
91
+ return text ? JSON.parse(text) : null;
92
+ }
93
+
94
+ /** Read a response body as an async iterable of decoded text chunks. */
95
+ async *_streamChunks(url, init) {
96
+ const resp = await this._fetch(url, init);
97
+ if (!resp.body) return;
98
+ const decoder = new TextDecoder();
99
+ try {
100
+ for await (const chunk of resp.body) {
101
+ const piece = decoder.decode(chunk, { stream: true });
102
+ if (piece) yield piece;
103
+ }
104
+ } catch (err) {
105
+ throw new APIConnectionError(`stream from ${this.baseURL} interrupted: ${err?.message}`, { cause: err });
106
+ }
107
+ const tail = decoder.decode();
108
+ if (tail) yield tail;
109
+ }
110
+
111
+ /** Send a JSON request and return the decoded JSON response (or null). */
112
+ async requestJSON(method, path, { body, params } = {}) {
113
+ return this._readJSON(await this._fetch(this._url(path, params), this._jsonInit(method, body)));
114
+ }
115
+
116
+ /**
117
+ * Stream a request body as decoded text chunks, for the server's streamed
118
+ * (`text/event-stream`) endpoints which are not JSON.
119
+ */
120
+ requestStream(method, path, { body, params } = {}) {
121
+ return this._streamChunks(this._url(path, params), this._jsonInit(method, body));
122
+ }
123
+
124
+ /**
125
+ * Send a multipart/form-data POST and return the decoded JSON response.
126
+ * `files` is an array of { field, filename, content, contentType }; `fields`
127
+ * an array of { name, value }.
128
+ */
129
+ async upload(path, { files = [], fields = [], params } = {}) {
130
+ return this._readJSON(await this._fetch(this._url(path, params), this._formInit(files, fields)));
131
+ }
132
+
133
+ /** Like {@link upload} but streams the response as decoded text chunks. */
134
+ uploadStream(path, { files = [], fields = [], params } = {}) {
135
+ return this._streamChunks(this._url(path, params), this._formInit(files, fields));
136
+ }
137
+ }