@meshxdata/fops 0.1.34 → 0.1.36
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/CHANGELOG.md +162 -1093
- package/package.json +1 -1
- package/src/agent/llm.js +3 -3
- package/src/plugins/bundled/fops-plugin-dai-ttyd/fops.plugin.json +6 -0
- package/src/plugins/bundled/fops-plugin-dai-ttyd/index.js +182 -0
- package/src/plugins/bundled/fops-plugin-dai-ttyd/lib/client.js +164 -0
- package/src/plugins/bundled/fops-plugin-dai-ttyd/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-foundation/index.js +12 -1
- package/src/ui/tui/App.js +1 -1
package/package.json
CHANGED
package/src/agent/llm.js
CHANGED
|
@@ -559,11 +559,11 @@ function resolveProvider(opts, { anthropicKey, openaiKey, azureConfig, useClaude
|
|
|
559
559
|
*/
|
|
560
560
|
function defaultModelForProvider(provider, opts, { anthropicKey, azureConfig }) {
|
|
561
561
|
if (opts.model) return opts.model;
|
|
562
|
-
if (provider === "anthropic" || provider === "claude_code") return process.env.ANTHROPIC_MODEL?.trim() || "claude-
|
|
562
|
+
if (provider === "anthropic" || provider === "claude_code") return process.env.ANTHROPIC_MODEL?.trim() || "claude-sonnet-4-6";
|
|
563
563
|
if (provider === "azure") return azureConfig?.deployment || "gpt-4o";
|
|
564
564
|
if (provider === "openai") return process.env.OPENAI_MODEL?.trim() || "gpt-5";
|
|
565
565
|
// Fallback
|
|
566
|
-
return anthropicKey ? (process.env.ANTHROPIC_MODEL?.trim() || "claude-
|
|
566
|
+
return anthropicKey ? (process.env.ANTHROPIC_MODEL?.trim() || "claude-sonnet-4-6") : azureConfig ? azureConfig.deployment : "gpt-5";
|
|
567
567
|
}
|
|
568
568
|
|
|
569
569
|
export async function streamAssistantReply(root, messages, systemContent, opts) {
|
|
@@ -794,7 +794,7 @@ export async function callClaudeWithTools(messages, systemContent, tools, opts =
|
|
|
794
794
|
const cached = getAnthropicModule();
|
|
795
795
|
const { default: Anthropic } = cached || await import("@anthropic-ai/sdk");
|
|
796
796
|
const client = new Anthropic({ apiKey: anthropicKey });
|
|
797
|
-
const model = opts.model || process.env.ANTHROPIC_MODEL?.trim() || "claude-
|
|
797
|
+
const model = opts.model || process.env.ANTHROPIC_MODEL?.trim() || "claude-sonnet-4-6";
|
|
798
798
|
const claudeTools = toClaudeTools(tools);
|
|
799
799
|
|
|
800
800
|
// Redact PII from context before sending
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { DaiTTYDClient } from "./lib/client.js";
|
|
3
|
+
|
|
4
|
+
const DIM = chalk.dim;
|
|
5
|
+
const OK = chalk.green;
|
|
6
|
+
const ERR = chalk.red;
|
|
7
|
+
const ACCENT = chalk.cyan;
|
|
8
|
+
const BOLD = chalk.bold;
|
|
9
|
+
const WARN = chalk.yellow;
|
|
10
|
+
|
|
11
|
+
export function register(api) {
|
|
12
|
+
const client = new DaiTTYDClient(api.config);
|
|
13
|
+
|
|
14
|
+
function clientFor({ url, jwt } = {}) {
|
|
15
|
+
if (!url && !jwt) return client;
|
|
16
|
+
return new DaiTTYDClient({
|
|
17
|
+
...api.config,
|
|
18
|
+
...(url && { baseUrl: url }),
|
|
19
|
+
...(jwt && { authToken: jwt }),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Doctor check ────────────────────────────────────────────────────────
|
|
24
|
+
api.registerDoctorCheck({
|
|
25
|
+
name: "dai-ttyd: auth token",
|
|
26
|
+
fn: async () => {
|
|
27
|
+
const token =
|
|
28
|
+
api.config?.authToken?.trim() || process.env.DAI_AUTH_TOKEN?.trim();
|
|
29
|
+
if (!token) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
message: "DAI_AUTH_TOKEN not set — configure via env or ~/.fops.json plugins.entries.fops-plugin-dai-ttyd.config.authToken",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, message: "DAI_AUTH_TOKEN is set" };
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ─── Agent tools ─────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
api.registerTool({
|
|
42
|
+
name: "ttyd_thread_create",
|
|
43
|
+
description: "Create a new TTYD conversation thread. Returns a thread_id to use with ttyd_ask.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
title: { type: "string", description: "Optional title for the thread" },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
async execute({ title } = {}) {
|
|
51
|
+
const threadId = await client.createThread(title || "fops session");
|
|
52
|
+
return JSON.stringify({ thread_id: threadId });
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
api.registerTool({
|
|
57
|
+
name: "ttyd_ask",
|
|
58
|
+
description:
|
|
59
|
+
"Ask a natural-language question to the DAI Talk-to-your-Data service. " +
|
|
60
|
+
"Returns the generated SQL and a text summary/answer. " +
|
|
61
|
+
"Optionally scoped to a dashboard or tile for context. " +
|
|
62
|
+
"If thread_id is omitted, a new thread is created automatically.",
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
question: { type: "string", description: "The natural-language question to ask" },
|
|
67
|
+
thread_id: { type: "number", description: "Existing thread id for multi-turn conversations" },
|
|
68
|
+
dashboard_id: { type: "number", description: "Optional dashboard id for context" },
|
|
69
|
+
tile_id: { type: "number", description: "Optional tile id for context" },
|
|
70
|
+
},
|
|
71
|
+
required: ["question"],
|
|
72
|
+
},
|
|
73
|
+
async execute({ question, thread_id, dashboard_id, tile_id }) {
|
|
74
|
+
const result = await client.ask(question, {
|
|
75
|
+
threadId: thread_id,
|
|
76
|
+
dashboardId: dashboard_id,
|
|
77
|
+
tileId: tile_id,
|
|
78
|
+
});
|
|
79
|
+
return JSON.stringify(result);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─── CLI commands ─────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
api.registerCommand((program) => {
|
|
86
|
+
const ttyd = program
|
|
87
|
+
.command("ttyd")
|
|
88
|
+
.description("Talk-to-your-Data — query data with natural language")
|
|
89
|
+
.option("--url <url>", "Override the TTYD base URL (e.g. https://api-live.dashboards.ai)")
|
|
90
|
+
.option("--jwt <token>", "Bearer token (overrides DAI_AUTH_TOKEN env)");
|
|
91
|
+
|
|
92
|
+
// fops ttyd ask "<question>"
|
|
93
|
+
ttyd
|
|
94
|
+
.command("ask <question>")
|
|
95
|
+
.description("Ask a natural-language question and stream the answer")
|
|
96
|
+
.option("--thread <id>", "Reuse an existing thread id (for follow-up questions)", (v) => Number(v))
|
|
97
|
+
.option("--dashboard <id>", "Scope to a dashboard id", (v) => Number(v))
|
|
98
|
+
.option("--tile <id>", "Scope to a tile id", (v) => Number(v))
|
|
99
|
+
.option("--json", "Output raw JSON instead of formatted text")
|
|
100
|
+
.option("--debug", "Print raw SSE events")
|
|
101
|
+
.action(async (question, opts) => {
|
|
102
|
+
const { url, jwt } = ttyd.opts();
|
|
103
|
+
const c = clientFor({ url, jwt });
|
|
104
|
+
|
|
105
|
+
let threadId = opts.thread ?? null;
|
|
106
|
+
if (threadId == null) {
|
|
107
|
+
process.stdout.write(DIM(" Creating thread... "));
|
|
108
|
+
try {
|
|
109
|
+
threadId = await c.createThread();
|
|
110
|
+
process.stdout.write(DIM(`thread ${threadId}\n`));
|
|
111
|
+
} catch (e) {
|
|
112
|
+
process.stdout.write(WARN(`skipped (${e.message})\n`));
|
|
113
|
+
// Proceed without a thread_id — server may create one implicitly
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log(ACCENT(`\n ── Asking ${"─".repeat(44)}`));
|
|
118
|
+
console.log(DIM(` ${question}\n`));
|
|
119
|
+
|
|
120
|
+
let sql = "";
|
|
121
|
+
const summaryParts = [];
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
for await (const event of c.askStream(question, threadId, {
|
|
125
|
+
dashboardId: opts.dashboard,
|
|
126
|
+
tileId: opts.tile,
|
|
127
|
+
})) {
|
|
128
|
+
if (opts.debug) console.error(DIM(`[sse] ${JSON.stringify(event)}`));
|
|
129
|
+
if (event.type === "sql") {
|
|
130
|
+
sql = event.content ?? "";
|
|
131
|
+
if (!opts.json) {
|
|
132
|
+
console.log(BOLD(" SQL:"));
|
|
133
|
+
for (const line of sql.split("\n")) {
|
|
134
|
+
console.log(DIM(` ${line}`));
|
|
135
|
+
}
|
|
136
|
+
console.log();
|
|
137
|
+
}
|
|
138
|
+
} else if (event.type === "answer") {
|
|
139
|
+
const chunk = event.content ?? "";
|
|
140
|
+
summaryParts.push(chunk);
|
|
141
|
+
if (!opts.json) process.stdout.write(chunk);
|
|
142
|
+
} else if (event.type === "error") {
|
|
143
|
+
throw new Error(event.message ?? "TTYD error");
|
|
144
|
+
} else if (event.type === "done") {
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.error(ERR(`\n ✗ ${e.message}`));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!opts.json) {
|
|
154
|
+
if (summaryParts.length > 0) process.stdout.write("\n");
|
|
155
|
+
if (threadId != null) {
|
|
156
|
+
console.log(DIM(`\n thread: ${threadId}`));
|
|
157
|
+
console.log(DIM(" Use --thread to continue this conversation.\n"));
|
|
158
|
+
} else {
|
|
159
|
+
console.log();
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
console.log(JSON.stringify({ thread_id: threadId, sql, summary: summaryParts.join("") }, null, 2));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// fops ttyd thread new [title]
|
|
167
|
+
const thread = ttyd
|
|
168
|
+
.command("thread")
|
|
169
|
+
.description("Manage TTYD threads");
|
|
170
|
+
|
|
171
|
+
thread
|
|
172
|
+
.command("new [title]")
|
|
173
|
+
.description("Create a new conversation thread")
|
|
174
|
+
.action(async (title = "fops session") => {
|
|
175
|
+
const { url, jwt } = ttyd.opts();
|
|
176
|
+
const c = clientFor({ url, jwt });
|
|
177
|
+
const threadId = await c.createThread(title);
|
|
178
|
+
console.log(OK(` ✓ Thread created: ${threadId}`));
|
|
179
|
+
console.log(DIM(` Use: fops ttyd ask --thread ${threadId} "<question>"\n`));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BASE_URL = "https://api.dashboards.ai";
|
|
5
|
+
|
|
6
|
+
function resolveBaseUrl(config) {
|
|
7
|
+
return (
|
|
8
|
+
config?.baseUrl?.trim() ||
|
|
9
|
+
process.env.DAI_TTYD_URL?.trim() ||
|
|
10
|
+
DEFAULT_BASE_URL
|
|
11
|
+
).replace(/\/+$/, "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveAuthToken(config) {
|
|
15
|
+
const raw = config?.authToken || process.env.DAI_AUTH_TOKEN || "";
|
|
16
|
+
return raw.replace(/\s+/g, ""); // strip whitespace/newlines from env or shell wrapping
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class DaiTTYDClient {
|
|
20
|
+
constructor(config = {}) {
|
|
21
|
+
this.baseUrl = resolveBaseUrl(config);
|
|
22
|
+
this.authToken = resolveAuthToken(config);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_headers() {
|
|
26
|
+
return {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
Accept: "application/json",
|
|
29
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** POST /ttyd/threads → thread id (number) */
|
|
34
|
+
async createThread(title = "fops session") {
|
|
35
|
+
const url = `${this.baseUrl}/ttyd/threads`;
|
|
36
|
+
let { status, data } = await request("POST", url, this._headers(), { title });
|
|
37
|
+
// Backend may return 400 "User with email already exists" on the first call
|
|
38
|
+
// when auto-provisioning the user — retry once, it should succeed
|
|
39
|
+
if (status === 400 && data.includes("already exists")) {
|
|
40
|
+
({ status, data } = await request("POST", url, this._headers(), { title }));
|
|
41
|
+
}
|
|
42
|
+
if (status >= 400) throw new Error(`Failed to create thread (HTTP ${status}): ${data}`);
|
|
43
|
+
const body = JSON.parse(data);
|
|
44
|
+
const id = body.id ?? body.thread_id;
|
|
45
|
+
if (id == null) throw new Error(`No thread id in response: ${data}`);
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* POST /ttyd/answer/stream — async generator yielding SSE event objects.
|
|
51
|
+
* Each event: { type: "sql"|"answer"|"done"|"error", content?: string, message?: string }
|
|
52
|
+
*/
|
|
53
|
+
async *askStream(question, threadId, { dashboardId, tileId } = {}) {
|
|
54
|
+
const url = `${this.baseUrl}/ttyd/answer/stream`;
|
|
55
|
+
const payload = { question, thread_id: threadId };
|
|
56
|
+
if (dashboardId != null) payload.dashboard_id = Number(dashboardId);
|
|
57
|
+
if (tileId != null) payload.tile_id = Number(tileId);
|
|
58
|
+
yield* streamSSE("POST", url, this._headers(), payload);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Convenience: create a thread, ask one question, collect full sql + summary. */
|
|
62
|
+
async ask(question, { threadId, dashboardId, tileId } = {}) {
|
|
63
|
+
const id = threadId ?? (await this.createThread());
|
|
64
|
+
let sql = "";
|
|
65
|
+
const summaryParts = [];
|
|
66
|
+
for await (const event of this.askStream(question, id, { dashboardId, tileId })) {
|
|
67
|
+
if (event.type === "sql") sql = event.content ?? "";
|
|
68
|
+
else if (event.type === "answer") summaryParts.push(event.content ?? "");
|
|
69
|
+
else if (event.type === "error") throw new Error(event.message ?? "TTYD error");
|
|
70
|
+
else if (event.type === "done") break;
|
|
71
|
+
}
|
|
72
|
+
return { threadId: id, sql, summary: summaryParts.join("") };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Verify connectivity — returns true/false. */
|
|
76
|
+
async ping() {
|
|
77
|
+
try {
|
|
78
|
+
const url = `${this.baseUrl}/ttyd/threads`;
|
|
79
|
+
const { status } = await request("POST", url, this._headers(), { title: "ping" });
|
|
80
|
+
return status < 500;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function request(method, url, headers, body, timeoutMs = 15_000) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const parsed = new URL(url);
|
|
92
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
93
|
+
const encoded = JSON.stringify(body);
|
|
94
|
+
const options = {
|
|
95
|
+
hostname: parsed.hostname,
|
|
96
|
+
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
|
|
97
|
+
path: parsed.pathname + parsed.search,
|
|
98
|
+
method,
|
|
99
|
+
headers: { ...headers, "Content-Length": Buffer.byteLength(encoded) },
|
|
100
|
+
timeout: timeoutMs,
|
|
101
|
+
};
|
|
102
|
+
const req = lib.request(options, (res) => {
|
|
103
|
+
let data = "";
|
|
104
|
+
res.on("data", (c) => { data += c; });
|
|
105
|
+
res.on("end", () => resolve({ status: res.statusCode, data }));
|
|
106
|
+
});
|
|
107
|
+
req.on("error", reject);
|
|
108
|
+
req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); });
|
|
109
|
+
req.write(encoded);
|
|
110
|
+
req.end();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Async generator that streams SSE from an HTTP endpoint.
|
|
116
|
+
* Yields parsed JSON objects from each "data: {...}" line.
|
|
117
|
+
*/
|
|
118
|
+
async function* streamSSE(method, url, headers, body, timeoutMs = 300_000) {
|
|
119
|
+
const parsed = new URL(url);
|
|
120
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
121
|
+
const encoded = JSON.stringify(body);
|
|
122
|
+
const options = {
|
|
123
|
+
hostname: parsed.hostname,
|
|
124
|
+
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
|
|
125
|
+
path: parsed.pathname + parsed.search,
|
|
126
|
+
method,
|
|
127
|
+
headers: {
|
|
128
|
+
...headers,
|
|
129
|
+
"Content-Length": Buffer.byteLength(encoded),
|
|
130
|
+
Accept: "text/event-stream",
|
|
131
|
+
},
|
|
132
|
+
timeout: timeoutMs,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const res = await new Promise((resolve, reject) => {
|
|
136
|
+
const req = lib.request(options, resolve);
|
|
137
|
+
req.on("error", reject);
|
|
138
|
+
req.on("timeout", () => { req.destroy(); reject(new Error("Stream timeout")); });
|
|
139
|
+
req.write(encoded);
|
|
140
|
+
req.end();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (res.statusCode >= 400) {
|
|
144
|
+
let errData = "";
|
|
145
|
+
for await (const chunk of res) errData += chunk;
|
|
146
|
+
throw new Error(`HTTP ${res.statusCode}: ${errData}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let buffer = "";
|
|
150
|
+
for await (const chunk of res) {
|
|
151
|
+
buffer += chunk.toString();
|
|
152
|
+
const lines = buffer.split("\n");
|
|
153
|
+
buffer = lines.pop(); // keep any incomplete line
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (!line.startsWith("data:")) continue;
|
|
156
|
+
const raw = line.slice("data:".length).trim();
|
|
157
|
+
if (!raw) continue;
|
|
158
|
+
let data;
|
|
159
|
+
try { data = JSON.parse(raw); } catch { continue; }
|
|
160
|
+
yield data;
|
|
161
|
+
if (data?.type === "done" || data?.type === "error") return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "module" }
|
|
@@ -1527,8 +1527,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
1527
1527
|
}
|
|
1528
1528
|
|
|
1529
1529
|
@objc func runUpdate() {
|
|
1530
|
+
updateItem.title = "⬆ Updating…"
|
|
1531
|
+
updateItem.isEnabled = false
|
|
1530
1532
|
let npm = "/opt/homebrew/bin/npm"
|
|
1531
|
-
|
|
1533
|
+
let p = Process()
|
|
1534
|
+
p.executableURL = URL(fileURLWithPath: npm)
|
|
1535
|
+
p.arguments = ["install", "-g", "@meshxdata/fops"]
|
|
1536
|
+
p.terminationHandler = { _ in
|
|
1537
|
+
DispatchQueue.main.async {
|
|
1538
|
+
updateItem.title = "✓ Updated — restart tray to apply"
|
|
1539
|
+
updateItem.isEnabled = false
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
try? p.run()
|
|
1532
1543
|
}
|
|
1533
1544
|
|
|
1534
1545
|
// Rebuild Compose submenu each time it opens
|
package/src/ui/tui/App.js
CHANGED
|
@@ -2790,7 +2790,7 @@ function TuiApp({ core, version, root }) {
|
|
|
2790
2790
|
h(Box, { flexShrink: 0 },
|
|
2791
2791
|
h(StatusBar, {
|
|
2792
2792
|
agentName: activeSession?.agent,
|
|
2793
|
-
model: core.opts.model || (providerLabel === "azure-openai" ? "azure-openai" : providerLabel === "openai" ? "openai" : "claude-sonnet"),
|
|
2793
|
+
model: core.opts.model || (providerLabel === "azure-openai" ? "azure-openai" : providerLabel === "openai" ? "openai" : process.env.ANTHROPIC_MODEL?.trim() || "claude-sonnet-4-6"),
|
|
2794
2794
|
tokenCount: tokenCount || (activeSession ? (core.sessions.get(activeId)?.tokenCount || 0) : 0),
|
|
2795
2795
|
cacheReadTokens,
|
|
2796
2796
|
version,
|