@knosi/cli 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/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @knosi/cli
2
+
3
+ Local Claude Code daemon for [Second Brain](https://github.com/zhousiyao03-cyber/second-brain).
4
+
5
+ Runs on your machine, picks up AI tasks from the hosted Second Brain instance, executes them via your local Claude CLI, and pushes results back.
6
+
7
+ ## Prerequisites
8
+
9
+ - [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) installed and logged in (`claude --version`)
10
+ - Node.js >= 20
11
+
12
+ ## Usage
13
+
14
+ ```bash
15
+ npx @knosi/cli --url https://your-second-brain.vercel.app
16
+ ```
17
+
18
+ The daemon will:
19
+ 1. Poll the server for queued AI tasks (chat + structured data)
20
+ 2. Execute them using your local Claude CLI
21
+ 3. Stream results back to the server
22
+
23
+ Press Ctrl+C to stop.
24
+
25
+ ## Options
26
+
27
+ | Flag | Description | Default |
28
+ |------|-------------|---------|
29
+ | `--url <url>` | Second Brain server URL | `https://second-brain-self-alpha.vercel.app` |
30
+ | `--model <model>` | Override Claude model | (from task) |
31
+ | `--once` | Process one round then exit | `false` |
32
+ | `--claude-bin <path>` | Path to Claude CLI binary | `claude` |
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@knosi/cli",
3
+ "version": "0.1.0",
4
+ "description": "Local Claude Code daemon for Second Brain — run AI tasks on your machine",
5
+ "license": "AGPL-3.0-or-later",
6
+ "type": "module",
7
+ "bin": {
8
+ "knosi": "./src/index.mjs"
9
+ },
10
+ "files": [
11
+ "src/"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "keywords": [
17
+ "second-brain",
18
+ "claude",
19
+ "ai",
20
+ "daemon"
21
+ ]
22
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * HTTP API client — communicates with the hosted Second Brain server.
3
+ */
4
+
5
+ let serverUrl = "";
6
+
7
+ export function configure(url) {
8
+ serverUrl = url.replace(/\/+$/, "");
9
+ }
10
+
11
+ export async function claimTask(taskType) {
12
+ const res = await fetch(`${serverUrl}/api/chat/claim`, {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify({ taskType }),
16
+ });
17
+ if (!res.ok) return null;
18
+ const data = await res.json();
19
+ return data.task ?? null;
20
+ }
21
+
22
+ export async function pushChatProgress(taskId, messages) {
23
+ if (messages.length === 0) return;
24
+ await fetch(`${serverUrl}/api/chat/progress`, {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({ taskId, messages }),
28
+ });
29
+ }
30
+
31
+ export async function completeTask(taskId, { totalText, structuredResult, error } = {}) {
32
+ const body = { taskId };
33
+ if (error) {
34
+ body.error = error;
35
+ } else {
36
+ if (totalText != null) body.totalText = totalText;
37
+ if (structuredResult != null) body.structuredResult = structuredResult;
38
+ }
39
+ const res = await fetch(`${serverUrl}/api/chat/complete`, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify(body),
43
+ });
44
+ if (!res.ok) {
45
+ throw new Error(`Complete API ${res.status}: ${await res.text()}`);
46
+ }
47
+ }
48
+
49
+ export async function sendHeartbeat(kind) {
50
+ await fetch(`${serverUrl}/api/daemon/ping`, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify({ kind, version: "@knosi/cli" }),
54
+ }).catch(() => {});
55
+ }
@@ -0,0 +1,97 @@
1
+ import { pushChatProgress, completeTask } from "./api.mjs";
2
+ import { spawnClaudeForChat } from "./spawn-claude.mjs";
3
+
4
+ function getMessageText(content) {
5
+ if (typeof content === "string") return content;
6
+ if (!Array.isArray(content)) return "";
7
+ return content
8
+ .filter((part) => part && part.type === "text")
9
+ .map((part) => part.text ?? "")
10
+ .join("");
11
+ }
12
+
13
+ function flattenMessagesToPrompt(messages) {
14
+ if (!Array.isArray(messages) || messages.length === 0) return "";
15
+
16
+ let lastUserIdx = -1;
17
+ for (let i = messages.length - 1; i >= 0; i--) {
18
+ if (messages[i]?.role === "user") {
19
+ lastUserIdx = i;
20
+ break;
21
+ }
22
+ }
23
+ if (lastUserIdx === -1) return "";
24
+
25
+ const history = messages.slice(0, lastUserIdx);
26
+ const lastUser = messages[lastUserIdx];
27
+ const currentQuestion = getMessageText(lastUser.content).trim();
28
+
29
+ if (history.length === 0) return currentQuestion;
30
+
31
+ const historyBlock = history
32
+ .map((m) => {
33
+ const role = m.role === "user" ? "用户" : "助手";
34
+ const text = getMessageText(m.content).trim();
35
+ return text ? `**${role}:** ${text}` : "";
36
+ })
37
+ .filter(Boolean)
38
+ .join("\n\n");
39
+
40
+ return `## 之前的对话历史\n\n${historyBlock}\n\n---\n\n## 当前问题\n\n${currentQuestion}`;
41
+ }
42
+
43
+ function ts() {
44
+ return new Date().toLocaleTimeString("en-GB", { hour12: false });
45
+ }
46
+
47
+ export async function handleChatTask(task) {
48
+ console.log(`[${ts()}] 🗨️ chat: ${task.id} (${task.model})`);
49
+
50
+ let seq = 0;
51
+ const pending = [];
52
+ let flushTimer = null;
53
+
54
+ async function flush() {
55
+ if (pending.length === 0) return;
56
+ const batch = pending.splice(0);
57
+ try {
58
+ await pushChatProgress(task.id, batch);
59
+ } catch {
60
+ // non-critical
61
+ }
62
+ }
63
+
64
+ function onText(delta) {
65
+ seq++;
66
+ pending.push({ seq, type: "text_delta", delta });
67
+ if (pending.length >= 8) {
68
+ flush();
69
+ } else if (!flushTimer) {
70
+ flushTimer = setTimeout(() => {
71
+ flushTimer = null;
72
+ flush();
73
+ }, 150);
74
+ }
75
+ }
76
+
77
+ try {
78
+ const prompt = flattenMessagesToPrompt(task.messages);
79
+ if (!prompt) throw new Error("Empty prompt from chat task messages");
80
+
81
+ const totalText = await spawnClaudeForChat({
82
+ prompt,
83
+ systemPrompt: task.systemPrompt || "",
84
+ model: task.model,
85
+ onText,
86
+ });
87
+
88
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
89
+ await flush();
90
+
91
+ await completeTask(task.id, { totalText });
92
+ console.log(`[${ts()}] ✅ chat done: ${task.id}`);
93
+ } catch (err) {
94
+ console.error(`[${ts()}] ❌ chat failed: ${task.id}`, err.message);
95
+ await completeTask(task.id, { error: err.message }).catch(() => {});
96
+ }
97
+ }
@@ -0,0 +1,35 @@
1
+ import { completeTask } from "./api.mjs";
2
+ import { spawnClaudeForStructured } from "./spawn-claude.mjs";
3
+
4
+ function ts() {
5
+ return new Date().toLocaleTimeString("en-GB", { hour12: false });
6
+ }
7
+
8
+ export async function handleStructuredTask(task) {
9
+ console.log(`[${ts()}] 📦 structured: ${task.id} (${task.model})`);
10
+
11
+ try {
12
+ const rawText = await spawnClaudeForStructured({
13
+ prompt: task.systemPrompt,
14
+ model: task.model,
15
+ });
16
+
17
+ // Extract JSON from possible markdown fences
18
+ const trimmed = rawText.trim();
19
+ const fencedMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
20
+ const candidate = fencedMatch?.[1]?.trim() ?? trimmed;
21
+ const start = candidate.indexOf("{");
22
+ const end = candidate.lastIndexOf("}");
23
+ const jsonText = (start !== -1 && end > start)
24
+ ? candidate.slice(start, end + 1)
25
+ : candidate;
26
+
27
+ JSON.parse(jsonText); // validate
28
+
29
+ await completeTask(task.id, { structuredResult: jsonText });
30
+ console.log(`[${ts()}] ✅ structured done: ${task.id}`);
31
+ } catch (err) {
32
+ console.error(`[${ts()}] ❌ structured failed: ${task.id}`, err.message);
33
+ await completeTask(task.id, { error: err.message }).catch(() => {});
34
+ }
35
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @knosi/cli — Local Claude Code daemon for Second Brain
4
+ *
5
+ * Usage:
6
+ * npx @knosi/cli --url https://your-instance.vercel.app
7
+ * npx @knosi/cli --once
8
+ */
9
+ import { execSync } from "node:child_process";
10
+ import { configure, claimTask, sendHeartbeat } from "./api.mjs";
11
+ import { setClaudeBin } from "./spawn-claude.mjs";
12
+ import { handleChatTask } from "./handler-chat.mjs";
13
+ import { handleStructuredTask } from "./handler-structured.mjs";
14
+
15
+ // ── Parse args ──────────────────────────────────────────────────────────
16
+ const args = process.argv.slice(2);
17
+
18
+ function getArg(flag) {
19
+ const idx = args.indexOf(flag);
20
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
21
+ }
22
+
23
+ const serverUrl = getArg("--url") || "https://second-brain-self-alpha.vercel.app";
24
+ const isOnce = args.includes("--once");
25
+ const claudeBinArg = getArg("--claude-bin") || "claude";
26
+
27
+ const CHAT_POLL_MS = 2_000;
28
+ const STRUCTURED_POLL_MS = 1_000;
29
+ const HEARTBEAT_MS = 120_000;
30
+ const MAX_CONCURRENT_CHAT = 3;
31
+ const MAX_CONCURRENT_STRUCTURED = 5;
32
+
33
+ // ── Preflight ───────────────────────────────────────────────────────────
34
+ function checkClaude() {
35
+ try {
36
+ const version = execSync(`${claudeBinArg} --version`, { encoding: "utf8" }).trim();
37
+ console.log(`✓ Claude CLI: ${version}`);
38
+ return true;
39
+ } catch {
40
+ console.error(`✗ Claude CLI not found at "${claudeBinArg}"`);
41
+ console.error(" Install: npm install -g @anthropic-ai/claude-code");
42
+ console.error(" Or specify: --claude-bin /path/to/claude");
43
+ return false;
44
+ }
45
+ }
46
+
47
+ // ── Main ────────────────────────────────────────────────────────────────
48
+ configure(serverUrl);
49
+ setClaudeBin(claudeBinArg);
50
+
51
+ if (!checkClaude()) process.exit(1);
52
+
53
+ function ts() {
54
+ return new Date().toLocaleTimeString("en-GB", { hour12: false });
55
+ }
56
+
57
+ let chatRunning = 0;
58
+ let structuredRunning = 0;
59
+
60
+ async function pollChat() {
61
+ if (chatRunning >= MAX_CONCURRENT_CHAT) return;
62
+ try {
63
+ const task = await claimTask("chat");
64
+ if (!task) return;
65
+ chatRunning++;
66
+ handleChatTask(task)
67
+ .catch(() => {})
68
+ .finally(() => { chatRunning--; });
69
+ } catch {
70
+ // server unreachable
71
+ }
72
+ }
73
+
74
+ async function pollStructured() {
75
+ if (structuredRunning >= MAX_CONCURRENT_STRUCTURED) return;
76
+ try {
77
+ const task = await claimTask("structured");
78
+ if (!task) return;
79
+ structuredRunning++;
80
+ handleStructuredTask(task)
81
+ .catch(() => {})
82
+ .finally(() => { structuredRunning--; });
83
+ } catch {
84
+ // server unreachable
85
+ }
86
+ }
87
+
88
+ if (isOnce) {
89
+ console.log("🔍 Single-run mode...");
90
+ await pollChat();
91
+ await pollStructured();
92
+ console.log("Done.");
93
+ } else {
94
+ console.log("");
95
+ console.log("🚀 Knosi AI Daemon");
96
+ console.log(` Server: ${serverUrl}`);
97
+ console.log(` Chat poll: ${CHAT_POLL_MS / 1000}s | Structured poll: ${STRUCTURED_POLL_MS / 1000}s`);
98
+ console.log(` Max concurrent: chat=${MAX_CONCURRENT_CHAT} structured=${MAX_CONCURRENT_STRUCTURED}`);
99
+ console.log("");
100
+ console.log(" Waiting for tasks... (Ctrl+C to stop)");
101
+ console.log("");
102
+
103
+ // Heartbeat
104
+ await sendHeartbeat("daemon");
105
+ setInterval(() => sendHeartbeat("daemon"), HEARTBEAT_MS);
106
+
107
+ // Poll loops
108
+ setInterval(pollChat, CHAT_POLL_MS);
109
+ setInterval(pollStructured, STRUCTURED_POLL_MS);
110
+
111
+ // Graceful shutdown
112
+ for (const sig of ["SIGINT", "SIGTERM"]) {
113
+ process.on(sig, () => {
114
+ console.log(`\n[${ts()}] daemon stopped`);
115
+ process.exit(0);
116
+ });
117
+ }
118
+ }
@@ -0,0 +1,119 @@
1
+ import { spawn as cpSpawn } from "node:child_process";
2
+
3
+ let claudeBin = "claude";
4
+
5
+ export function setClaudeBin(bin) {
6
+ claudeBin = bin;
7
+ }
8
+
9
+ /**
10
+ * Chat mode: streams text deltas via onText callback, returns final text.
11
+ */
12
+ export function spawnClaudeForChat({ prompt, systemPrompt, model, onText }) {
13
+ return new Promise((resolve, reject) => {
14
+ const args = [
15
+ "-p", prompt,
16
+ "--system-prompt", systemPrompt,
17
+ "--tools", "",
18
+ "--output-format", "stream-json",
19
+ "--include-partial-messages",
20
+ "--verbose",
21
+ ];
22
+ if (model) args.push("--model", model);
23
+
24
+ const child = cpSpawn(claudeBin, args, {
25
+ detached: true,
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ });
28
+
29
+ const stderrChunks = [];
30
+ let finalResult = "";
31
+ let lineBuf = "";
32
+
33
+ child.stdout.on("data", (chunk) => {
34
+ lineBuf += chunk.toString("utf8");
35
+ const lines = lineBuf.split("\n");
36
+ lineBuf = lines.pop();
37
+
38
+ for (const line of lines) {
39
+ if (!line.trim()) continue;
40
+ try {
41
+ const event = JSON.parse(line);
42
+ if (event.type === "stream_event" && event.event) {
43
+ const se = event.event;
44
+ if (
45
+ se.type === "content_block_delta" &&
46
+ se.delta?.type === "text_delta" &&
47
+ typeof se.delta.text === "string"
48
+ ) {
49
+ onText(se.delta.text);
50
+ }
51
+ continue;
52
+ }
53
+ if (event.type === "result" && typeof event.result === "string") {
54
+ finalResult = event.result;
55
+ }
56
+ } catch {
57
+ // skip
58
+ }
59
+ }
60
+ });
61
+
62
+ child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
63
+ child.on("error", (err) => reject(err));
64
+ child.on("close", (code) => {
65
+ if (code !== 0) {
66
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
67
+ reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr}` : ""}`));
68
+ return;
69
+ }
70
+ resolve(finalResult);
71
+ });
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Structured mode: non-streaming, returns full text result.
77
+ */
78
+ export function spawnClaudeForStructured({ prompt, model }) {
79
+ return new Promise((resolve, reject) => {
80
+ const systemPrompt =
81
+ "You are a structured data generator. Always return exactly one JSON object with no markdown fences or extra prose.";
82
+
83
+ const args = [
84
+ "-p", prompt,
85
+ "--system-prompt", systemPrompt,
86
+ "--tools", "",
87
+ "--output-format", "json",
88
+ "--verbose",
89
+ ];
90
+ if (model) args.push("--model", model);
91
+
92
+ const child = cpSpawn(claudeBin, args, {
93
+ detached: true,
94
+ stdio: ["ignore", "pipe", "pipe"],
95
+ });
96
+
97
+ const stderrChunks = [];
98
+ let stdout = "";
99
+
100
+ child.stdout.on("data", (chunk) => {
101
+ stdout += chunk.toString("utf8");
102
+ });
103
+ child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
104
+ child.on("error", (err) => reject(err));
105
+ child.on("close", (code) => {
106
+ if (code !== 0) {
107
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
108
+ reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr}` : ""}`));
109
+ return;
110
+ }
111
+ try {
112
+ const parsed = JSON.parse(stdout);
113
+ resolve(typeof parsed.result === "string" ? parsed.result : stdout);
114
+ } catch {
115
+ resolve(stdout);
116
+ }
117
+ });
118
+ });
119
+ }