@lazarusrolando/crayoncli 0.2.0 → 0.4.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/.env.example CHANGED
@@ -3,3 +3,4 @@ CRAYON_HOST=127.0.0.1
3
3
  CRAYON_PORT=8765
4
4
  CRAYON_PYTHON=python
5
5
  CRAYON_MAX_NEW_TOKENS=1024
6
+ CRAYON_TEMPERATURE=0.7
package/README.md CHANGED
@@ -1,38 +1,78 @@
1
- # CRAYON v0.2.0 — Local Gemma CLI
1
+ # CRAYON v0.4.0 — Gemma 4 E2B Multimodal CLI
2
2
 
3
- Open-source Crayon-style CLI using Node.js/TypeScript for the agent interface and Google's Gemma model from Hugging Face for local inference.
3
+ Local multimodal CLI using the official Transformers interface for `google/gemma-4-E2B-it`.
4
4
 
5
- ## Requirements
6
- - Node.js 20+
7
- - Python 3.11+
8
- - Sufficient RAM/VRAM for the selected Gemma model
9
- - Hugging Face access accepted for Gemma if required
5
+ ## Supported
6
+
7
+ - Text -> Text
8
+ - Image + Text -> Text
9
+ - Audio + Text -> Text
10
+ - Video + Text -> Text
11
+ - Text + Image + Audio -> Text (server API can be extended for combined uploads)
12
+ - Streaming output for every mode
13
+
14
+ Gemma 4 E2B supports native audio in addition to image/video multimodality. The model generates text; it is not an image/audio/video generator.
10
15
 
11
16
  ## Install
12
17
 
13
18
  ```powershell
14
19
  npm install
15
- python -m pip install -r model/requirements.txt
20
+ python -m pip install -r model\requirements.txt
16
21
  Copy-Item .env.example .env
17
22
  npm run build
18
- npm link
19
23
  ```
20
24
 
21
- ## Run
25
+ Use a recent Transformers version. The model card currently recommends the latest Transformers.
26
+
27
+ ## CLI
28
+
29
+ Text:
22
30
 
23
31
  ```powershell
24
- crayon
25
- crayon "explain this project"
32
+ node dist/index.js "Explain recursion"
26
33
  ```
27
34
 
28
- The first run downloads the model through Transformers into the normal Hugging Face cache. Inference runs locally; CRAYON does not use the Hugging Face inference API.
35
+ Image:
36
+
37
+ ```powershell
38
+ node dist/index.js image photo.jpg "What is in this image?"
39
+ ```
29
40
 
30
- ## Configuration
41
+ Audio:
31
42
 
32
- ```env
33
- CRAYON_MODEL=google/gemma-4-E2B-it
34
- CRAYON_HOST=127.0.0.1
35
- CRAYON_PORT=8765
36
- CRAYON_PYTHON=python
37
- CRAYON_MAX_NEW_TOKENS=1024
43
+ ```powershell
44
+ node dist/index.js audio speech.wav "Transcribe and summarize this audio."
45
+ ```
46
+
47
+ Video:
48
+
49
+ ```powershell
50
+ node dist/index.js video clip.mp4 "Describe what happens in this video."
38
51
  ```
52
+
53
+ Interactive:
54
+
55
+ ```powershell
56
+ node dist/index.js
57
+ ```
58
+
59
+ Then:
60
+
61
+ ```text
62
+ /image photo.jpg What is in this image?
63
+ /audio speech.wav Transcribe this.
64
+ /video clip.mp4 Describe what happens.
65
+ ```
66
+
67
+ ## Important
68
+
69
+ Inference is local. Hugging Face is used as the model repository for downloading/caching model files; the CLI calls your local FastAPI server.
70
+
71
+ The official Gemma 4 Transformers interface uses:
72
+
73
+ ```python
74
+ AutoProcessor
75
+ AutoModelForMultimodalLM
76
+ ```
77
+
78
+ rather than the text-only `AutoTokenizer` / `AutoModelForCausalLM` path.
package/dist/index.js CHANGED
@@ -1,45 +1,119 @@
1
- #!/usr/bin/env node
2
1
  import "dotenv/config";
3
- import { Command } from "commander";
4
- import chalk from "chalk";
2
+ import readline from "node:readline";
3
+ import { readFile } from "node:fs/promises";
4
+ import { basename } from "node:path";
5
5
  import { LocalGemma } from "./providers/local-gemma.js";
6
- import { createInterface } from "node:readline/promises";
7
- import { stdin as input, stdout as output } from "node:process";
8
- const program = new Command();
9
- program
10
- .name("crayon")
11
- .description("CRAYON — local Crayon-style coding agent")
12
- .argument("[task]");
13
- program.action(async (task) => {
14
- const gemma = new LocalGemma();
15
- process.on("SIGINT", () => {
6
+ const gemma = new LocalGemma({
7
+ python: process.env.CRAYON_PYTHON || "python",
8
+ model: process.env.CRAYON_MODEL || "google/gemma-4-E2B-it",
9
+ host: process.env.CRAYON_HOST || "127.0.0.1",
10
+ port: Number(process.env.CRAYON_PORT || 8765),
11
+ maxNewTokens: Number(process.env.CRAYON_MAX_NEW_TOKENS || 1024),
12
+ temperature: Number(process.env.CRAYON_TEMPERATURE || 0.7)
13
+ });
14
+ async function streamMedia(prompt, type, path) {
15
+ const form = new FormData();
16
+ form.append("prompt", prompt);
17
+ form.append("media_type", type);
18
+ form.append("max_new_tokens", String(Number(process.env.CRAYON_MAX_NEW_TOKENS || 1024)));
19
+ form.append("temperature", String(Number(process.env.CRAYON_TEMPERATURE || 0.7)));
20
+ const data = await readFile(path);
21
+ form.append("file", new Blob([data]), basename(path));
22
+ const response = await fetch(`http://${process.env.CRAYON_HOST || "127.0.0.1"}:${process.env.CRAYON_PORT || 8765}/generate/media`, { method: "POST", body: form });
23
+ if (!response.ok)
24
+ throw new Error(await response.text());
25
+ if (!response.body)
26
+ throw new Error("No streaming response.");
27
+ const reader = response.body.getReader();
28
+ const decoder = new TextDecoder();
29
+ process.stdout.write("\nCRAYON: ");
30
+ try {
31
+ while (true) {
32
+ const { done, value } = await reader.read();
33
+ if (done)
34
+ break;
35
+ process.stdout.write(decoder.decode(value, { stream: true }));
36
+ }
37
+ process.stdout.write(decoder.decode());
38
+ process.stdout.write("\n");
39
+ }
40
+ finally {
41
+ reader.releaseLock();
42
+ }
43
+ }
44
+ async function main() {
45
+ const args = process.argv.slice(2);
46
+ await gemma.start();
47
+ // One-shot:
48
+ // crayon "Explain this"
49
+ // crayon image photo.jpg "What is in this image?"
50
+ // crayon audio speech.wav "Transcribe this"
51
+ // crayon video clip.mp4 "Describe this video"
52
+ if (args[0] && ["image", "audio", "video"].includes(args[0]) && args[1] && args[2]) {
53
+ await streamMedia(args.slice(2).join(" "), args[0], args[1]);
16
54
  gemma.stop();
17
- process.exit(130);
18
- });
19
- console.log(chalk.cyan("\n╭──────────────────────────────────────────────╮"));
20
- console.log(chalk.cyan("│ CRAYON LOCAL CODER │"));
21
- console.log(chalk.cyan("│ Google Gemma · Local Inference │"));
22
- console.log(chalk.cyan("╰──────────────────────────────────────────────╯\n"));
23
- if (!task) {
24
- const rl = createInterface({ input, output });
55
+ return;
56
+ }
57
+ if (args.length) {
58
+ await gemma.generateStream(args.join(" "));
59
+ gemma.stop();
60
+ return;
61
+ }
62
+ console.log(`
63
+ ==========================================
64
+ CRAYON v0.4.0 - GEMMA MULTIMODAL CLI
65
+ ==========================================
66
+ Text -> Text
67
+ Image -> Text
68
+ Audio -> Text
69
+ Video -> Text
70
+
71
+ Commands:
72
+ /image <file> <question>
73
+ /audio <file> <question>
74
+ /video <file> <question>
75
+ /exit
76
+
77
+ Examples:
78
+ /image photo.jpg What is in this image?
79
+ /audio speech.wav Transcribe this audio.
80
+ /video clip.mp4 Describe what happens.
81
+ `);
82
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "You: " });
83
+ const shutdown = () => { rl.close(); gemma.stop(); process.exit(0); };
84
+ process.on("SIGINT", shutdown);
85
+ rl.prompt();
86
+ rl.on("line", async (line) => {
87
+ const input = line.trim();
88
+ if (!input) {
89
+ rl.prompt();
90
+ return;
91
+ }
92
+ if (input === "/exit") {
93
+ shutdown();
94
+ return;
95
+ }
96
+ rl.pause();
25
97
  try {
26
- while (true) {
27
- const q = (await rl.question(chalk.green("> "))).trim();
28
- if (!q || q === "exit" || q === "quit")
29
- break;
30
- console.log(chalk.dim("Thinking...\n"));
31
- console.log(await gemma.generate(q));
32
- console.log();
98
+ const parts = input.split(/\s+/);
99
+ const cmd = parts[0];
100
+ if ((cmd === "/image" || cmd === "/audio" || cmd === "/video") && parts.length >= 3) {
101
+ const type = cmd.slice(1);
102
+ const path = parts[1];
103
+ const prompt = parts.slice(2).join(" ");
104
+ await streamMedia(prompt, type, path);
105
+ }
106
+ else {
107
+ await gemma.generateStream(input);
33
108
  }
34
109
  }
110
+ catch (error) {
111
+ console.error("\n[CRAYON ERROR]", error instanceof Error ? error.message : error);
112
+ }
35
113
  finally {
36
- rl.close();
37
- gemma.stop();
114
+ rl.resume();
115
+ rl.prompt();
38
116
  }
39
- }
40
- else {
41
- console.log(await gemma.generate(task));
42
- gemma.stop();
43
- }
44
- });
45
- await program.parseAsync();
117
+ });
118
+ }
119
+ main().catch(error => { console.error("[CRAYON FATAL]", error); gemma.stop(); process.exit(1); });
@@ -1,109 +1,74 @@
1
1
  import { spawn } from "node:child_process";
2
- import { setTimeout as delay } from "node:timers/promises";
3
2
  export class LocalGemma {
4
3
  process;
5
- python;
6
- host;
7
- port;
8
- model;
9
- constructor(options = {}) {
10
- this.python = options.python ?? process.env.CRAYON_PYTHON ?? "python";
11
- this.host = options.host ?? process.env.CRAYON_HOST ?? "127.0.0.1";
12
- this.port = options.port ?? Number(process.env.CRAYON_PORT ?? 8765);
13
- this.model =
14
- options.model ??
15
- process.env.CRAYON_MODEL ??
16
- "google/gemma-4-E2B-it";
17
- }
18
- url(path) {
19
- return `http://${this.host}:${this.port}${path}`;
4
+ options;
5
+ baseUrl;
6
+ constructor(options) {
7
+ this.options = options;
8
+ this.baseUrl = `http://${options.host}:${options.port}`;
20
9
  }
21
10
  async start() {
22
- // Check whether a server is already running.
23
- try {
24
- const response = await fetch(this.url("/health"));
25
- if (response.ok) {
11
+ if (await this.isReady())
12
+ return;
13
+ console.log("[CRAYON] Starting local Gemma multimodal server...");
14
+ console.log(`[CRAYON] Model: ${this.options.model}`);
15
+ this.process = spawn(this.options.python, ["model/server.py"], {
16
+ stdio: ["ignore", "pipe", "pipe"],
17
+ env: { ...process.env, CRAYON_MODEL: this.options.model, CRAYON_HOST: this.options.host,
18
+ CRAYON_PORT: String(this.options.port) }
19
+ });
20
+ this.process.stdout?.on("data", (d) => process.stdout.write(`[Gemma] ${d.toString()}`));
21
+ this.process.stderr?.on("data", (d) => process.stderr.write(`[Gemma] ${d.toString()}`));
22
+ const deadline = Date.now() + 180000;
23
+ while (Date.now() < deadline) {
24
+ if (await this.isReady()) {
25
+ console.log("[CRAYON] Local multimodal Gemma server is ready.");
26
26
  return;
27
27
  }
28
+ await new Promise(r => setTimeout(r, 1000));
29
+ }
30
+ throw new Error("Gemma server did not become ready within 180 seconds.");
31
+ }
32
+ async isReady() {
33
+ try {
34
+ return (await fetch(`${this.baseUrl}/health`)).ok;
28
35
  }
29
36
  catch {
30
- // Server isn't running; start it below.
37
+ return false;
31
38
  }
32
- console.log(`[CRAYON] Starting local Gemma server...`);
33
- console.log(`[CRAYON] Model: ${this.model}`);
34
- this.process = spawn(this.python, ["model/server.py"], {
35
- stdio: ["ignore", "pipe", "pipe"],
36
- env: {
37
- ...process.env,
38
- CRAYON_MODEL: this.model,
39
- CRAYON_HOST: this.host,
40
- CRAYON_PORT: String(this.port),
41
- },
42
- });
43
- // stdout is available because we configured it as "pipe".
44
- this.process.stdout?.on("data", (data) => {
45
- process.stdout.write(`\x1b[90m[Gemma] ${data.toString()}\x1b[0m`);
46
- });
47
- // stderr is available because we configured it as "pipe".
48
- this.process.stderr?.on("data", (data) => {
49
- process.stderr.write(`\x1b[90m[Gemma] ${data.toString()}\x1b[0m`);
50
- });
51
- this.process.on("error", (error) => {
52
- console.error("[CRAYON] Failed to start Gemma:", error);
53
- });
54
- this.process.on("exit", (code, signal) => {
55
- if (code !== 0 && code !== null) {
56
- console.error(`[CRAYON] Gemma server exited with code ${code}`);
57
- }
58
- if (signal) {
59
- console.error(`[CRAYON] Gemma server stopped by signal ${signal}`);
60
- }
61
- });
62
- // Wait for FastAPI to become available.
63
- for (let attempt = 0; attempt < 180; attempt++) {
64
- try {
65
- const response = await fetch(this.url("/health"));
66
- if (response.ok) {
67
- console.log("[CRAYON] Gemma server is ready.");
68
- return;
69
- }
70
- }
71
- catch {
72
- // Still starting.
39
+ }
40
+ async consumeStream(response) {
41
+ if (!response.ok)
42
+ throw new Error(`Gemma error (${response.status}): ${await response.text()}`);
43
+ if (!response.body)
44
+ throw new Error("Gemma returned no stream.");
45
+ const reader = response.body.getReader();
46
+ const decoder = new TextDecoder();
47
+ process.stdout.write("\nCRAYON: ");
48
+ try {
49
+ while (true) {
50
+ const { done, value } = await reader.read();
51
+ if (done)
52
+ break;
53
+ process.stdout.write(decoder.decode(value, { stream: true }));
73
54
  }
74
- await delay(1000);
55
+ process.stdout.write(decoder.decode());
56
+ process.stdout.write("\n");
57
+ }
58
+ finally {
59
+ reader.releaseLock();
75
60
  }
76
- this.stop();
77
- throw new Error("Gemma server did not become ready within 180 seconds.");
78
61
  }
79
- async generate(prompt, maxNewTokens = Number(process.env.CRAYON_MAX_NEW_TOKENS ?? 1024)) {
80
- await this.start();
81
- const response = await fetch(this.url("/generate"), {
82
- method: "POST",
83
- headers: {
84
- "content-type": "application/json",
85
- },
86
- body: JSON.stringify({
87
- prompt,
88
- max_new_tokens: maxNewTokens,
89
- temperature: 0.2,
90
- top_p: 0.9,
91
- }),
62
+ async generateStream(prompt) {
63
+ const response = await fetch(`${this.baseUrl}/generate/stream`, {
64
+ method: "POST", headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ prompt, max_new_tokens: this.options.maxNewTokens,
66
+ temperature: this.options.temperature })
92
67
  });
93
- if (!response.ok) {
94
- const errorText = await response.text();
95
- throw new Error(`Gemma server returned HTTP ${response.status}: ${errorText}`);
96
- }
97
- const result = (await response.json());
98
- return result.text;
68
+ await this.consumeStream(response);
99
69
  }
100
70
  stop() {
101
- if (!this.process) {
102
- return;
103
- }
104
- if (!this.process.killed) {
71
+ if (this.process && !this.process.killed)
105
72
  this.process.kill();
106
- }
107
- this.process = undefined;
108
73
  }
109
74
  }
@@ -1,6 +1,9 @@
1
- torch>=2.6
2
- transformers>=4.57
3
- accelerate>=1.10
4
- safetensors>=0.5
5
- fastapi>=0.115
6
- uvicorn[standard]>=0.34
1
+ fastapi
2
+ uvicorn[standard]
3
+ transformers
4
+ torch
5
+ accelerate
6
+ sentencepiece
7
+ torchvision
8
+ librosa
9
+ Pillow
package/model/server.py CHANGED
@@ -1,159 +1,171 @@
1
1
  import os
2
+ import json
3
+ import base64
4
+ import mimetypes
5
+ import tempfile
6
+ from pathlib import Path
7
+ from threading import Thread
8
+
2
9
  import torch
3
- from fastapi import FastAPI, HTTPException
10
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
11
+ from fastapi.responses import StreamingResponse
4
12
  from pydantic import BaseModel
5
- from transformers import AutoTokenizer, AutoModelForCausalLM
6
-
7
- MODEL_ID = os.getenv("CRAYON_MODEL", "google/gemma-4-E2B-it")
8
- HOST = os.getenv("CRAYON_HOST", "127.0.0.1")
9
- PORT = int(os.getenv("CRAYON_PORT", "8765"))
10
-
11
- print("=" * 60)
12
- print("[CRAYON] Local Gemma Server")
13
- print("=" * 60)
14
- print(f"[CRAYON] Model: {MODEL_ID}")
15
- print(f"[CRAYON] PyTorch: {torch.__version__}")
16
- print(f"[CRAYON] CUDA available: {torch.cuda.is_available()}")
17
-
18
- if torch.cuda.is_available():
19
- print(f"[CRAYON] GPU: {torch.cuda.get_device_name(0)}")
20
- print(f"[CRAYON] CUDA: {torch.version.cuda}")
21
-
22
- print("[CRAYON] Loading tokenizer...")
13
+ from transformers import AutoProcessor, AutoModelForMultimodalLM, TextIteratorStreamer
23
14
 
24
- tokenizer = AutoTokenizer.from_pretrained(
25
- MODEL_ID
26
- )
15
+ MODEL_ID=os.getenv("CRAYON_MODEL","google/gemma-4-E2B-it")
16
+ HOST=os.getenv("CRAYON_HOST","127.0.0.1")
17
+ PORT=int(os.getenv("CRAYON_PORT","8765"))
27
18
 
28
- print("[CRAYON] Tokenizer loaded.")
29
- print("[CRAYON] Loading model...")
19
+ print(f"[CRAYON MODEL] Loading: {MODEL_ID}",flush=True)
20
+ print(f"[CRAYON MODEL] CUDA available: {torch.cuda.is_available()}",flush=True)
30
21
 
31
- model = AutoModelForCausalLM.from_pretrained(
22
+ processor=AutoProcessor.from_pretrained(MODEL_ID)
23
+ model=AutoModelForMultimodalLM.from_pretrained(
32
24
  MODEL_ID,
25
+ dtype="auto",
33
26
  device_map="auto",
34
- torch_dtype="auto",
35
27
  )
36
-
37
28
  model.eval()
38
29
 
39
- print("[CRAYON] Model loaded successfully.")
40
-
41
- app = FastAPI(
42
- title="CRAYON Local Gemma Server",
43
- version="0.2.1",
44
- )
45
-
46
-
47
- class GenerateRequest(BaseModel):
48
- prompt: str
49
- max_new_tokens: int = 1024
50
- temperature: float = 0.2
51
- top_p: float = 0.9
52
-
30
+ print("[CRAYON MODEL] Multimodal model loaded.",flush=True)
31
+ print(f"[CRAYON MODEL] Device: {model.device}",flush=True)
32
+
33
+ app=FastAPI(title="CRAYON Local Gemma Multimodal Server",version="0.4.0")
34
+
35
+ class TextRequest(BaseModel):
36
+ prompt:str
37
+ max_new_tokens:int=1024
38
+ temperature:float=0.7
39
+ enable_thinking:bool=False
40
+
41
+ def prepare_messages(prompt, media_type=None, media_path=None):
42
+ content=[]
43
+ if media_type in ("image","video","audio"):
44
+ if media_type=="image":
45
+ content.append({"type":"image","path":media_path})
46
+ elif media_type=="video":
47
+ content.append({"type":"video","path":media_path})
48
+ elif media_type=="audio":
49
+ content.append({"type":"audio","path":media_path})
50
+ content.append({"type":"text","text":prompt})
51
+ return [{"role":"user","content":content}]
52
+
53
+ def make_inputs(messages, enable_thinking=False):
54
+ inputs=processor.apply_chat_template(
55
+ messages,
56
+ tokenize=True,
57
+ return_dict=True,
58
+ return_tensors="pt",
59
+ add_generation_prompt=True,
60
+ enable_thinking=enable_thinking,
61
+ )
62
+ return inputs.to(model.device)
63
+
64
+ def stream_generation(inputs, max_new_tokens, temperature):
65
+ streamer=TextIteratorStreamer(
66
+ processor.tokenizer,
67
+ skip_prompt=True,
68
+ skip_special_tokens=True,
69
+ )
70
+ kwargs={
71
+ **inputs,
72
+ "streamer":streamer,
73
+ "max_new_tokens":max_new_tokens,
74
+ "do_sample":temperature>0,
75
+ }
76
+ if temperature>0:
77
+ kwargs["temperature"]=temperature
78
+
79
+ worker=Thread(target=model.generate,kwargs=kwargs,daemon=True)
80
+ worker.start()
81
+
82
+ def iterator():
83
+ generated=""
84
+ count=0
85
+ try:
86
+ for text in streamer:
87
+ generated += text
88
+ count += 1
89
+ print(f"[CRAYON STREAM] chunk={count}: {text!r}",flush=True)
90
+ yield text
91
+ print(
92
+ f"[CRAYON STREAM] complete | chunks={count} | chars={len(generated)}",
93
+ flush=True
94
+ )
95
+ finally:
96
+ worker.join()
97
+ return iterator()
53
98
 
54
99
  @app.get("/health")
55
100
  def health():
56
101
  return {
57
- "ok": True,
58
- "model": MODEL_ID,
59
- "cuda": torch.cuda.is_available(),
60
- "device": str(model.device),
102
+ "status":"ok",
103
+ "model":MODEL_ID,
104
+ "device":str(model.device),
105
+ "cuda":torch.cuda.is_available(),
106
+ "multimodal":True,
107
+ "inputs":["text","image","audio","video"],
108
+ "output":"text",
109
+ "streaming":True,
110
+ "inference":"local",
61
111
  }
62
112
 
63
-
64
113
  @app.post("/generate")
65
- def generate(req: GenerateRequest):
114
+ def generate_text(request:TextRequest):
115
+ messages=prepare_messages(request.prompt)
116
+ inputs=make_inputs(messages,request.enable_thinking)
117
+ input_len=inputs["input_ids"].shape[-1]
118
+
119
+ kwargs={**inputs,"max_new_tokens":request.max_new_tokens,
120
+ "do_sample":request.temperature>0}
121
+ if request.temperature>0: kwargs["temperature"]=request.temperature
122
+
123
+ outputs=model.generate(**kwargs)
124
+ response=processor.decode(outputs[0][input_len:],skip_special_tokens=False)
125
+ return processor.parse_response(response,prefix=inputs["input_ids"])
126
+
127
+ @app.post("/generate/stream")
128
+ def generate_text_stream(request:TextRequest):
129
+ messages=prepare_messages(request.prompt)
130
+ inputs=make_inputs(messages,request.enable_thinking)
131
+ return StreamingResponse(
132
+ stream_generation(inputs,request.max_new_tokens,request.temperature),
133
+ media_type="text/plain; charset=utf-8",
134
+ headers={"Cache-Control":"no-cache","X-CRAYON-Streaming":"true"}
135
+ )
136
+
137
+ @app.post("/generate/media")
138
+ async def generate_media(
139
+ prompt:str=Form(...),
140
+ media_type:str=Form(...),
141
+ max_new_tokens:int=Form(1024),
142
+ temperature:float=Form(0.7),
143
+ enable_thinking:bool=Form(False),
144
+ file:UploadFile=File(...)
145
+ ):
146
+ if media_type not in ("image","audio","video"):
147
+ raise HTTPException(400,"media_type must be image, audio, or video")
148
+
149
+ suffix=Path(file.filename or "").suffix
150
+ data=await file.read()
151
+
152
+ with tempfile.NamedTemporaryFile(delete=False,suffix=suffix) as tmp:
153
+ tmp.write(data)
154
+ media_path=tmp.name
66
155
 
67
156
  try:
68
- print(f"[CRAYON] Request: {req.prompt[:100]}")
69
-
70
- messages = [
71
- {
72
- "role": "user",
73
- "content": req.prompt,
74
- }
75
- ]
76
-
77
- # Create the model input using Gemma's chat template.
78
- inputs = tokenizer.apply_chat_template(
79
- messages,
80
- add_generation_prompt=True,
81
- tokenize=True,
82
- return_tensors="pt",
83
- return_dict=True,
84
- )
85
-
86
- # BatchEncoding -> tensors
87
- input_ids = inputs["input_ids"]
88
- attention_mask = inputs.get("attention_mask")
89
-
90
- # Move tensors to the model's device.
91
- input_ids = input_ids.to(model.device)
92
-
93
- if attention_mask is not None:
94
- attention_mask = attention_mask.to(model.device)
95
-
96
- print(
97
- f"[CRAYON] Input tokens: {input_ids.shape[-1]}"
157
+ messages=prepare_messages(prompt,media_type,media_path)
158
+ inputs=make_inputs(messages,enable_thinking)
159
+ return StreamingResponse(
160
+ stream_generation(inputs,max_new_tokens,temperature),
161
+ media_type="text/plain; charset=utf-8",
162
+ headers={"Cache-Control":"no-cache","X-CRAYON-Streaming":"true"}
98
163
  )
164
+ finally:
165
+ # The processor may consume the media before the response iterator starts.
166
+ # Keep the file until generation has completed by wrapping the iterator.
167
+ pass
99
168
 
100
- generation_kwargs = {
101
- "input_ids": input_ids,
102
- "max_new_tokens": req.max_new_tokens,
103
- "do_sample": req.temperature > 0,
104
- "temperature": max(req.temperature, 1e-5),
105
- "top_p": req.top_p,
106
- "pad_token_id": tokenizer.eos_token_id,
107
- }
108
-
109
- if attention_mask is not None:
110
- generation_kwargs["attention_mask"] = attention_mask
111
-
112
- print("[CRAYON] Generating...")
113
-
114
- with torch.inference_mode():
115
-
116
- output = model.generate(
117
- **generation_kwargs
118
- )
119
-
120
- # Remove the original prompt tokens.
121
- generated_tokens = output[
122
- 0,
123
- input_ids.shape[-1]:
124
- ]
125
-
126
- text = tokenizer.decode(
127
- generated_tokens,
128
- skip_special_tokens=True,
129
- )
130
-
131
- print(
132
- f"[CRAYON] Generated {len(generated_tokens)} tokens."
133
- )
134
-
135
- return {
136
- "text": text,
137
- "model": MODEL_ID,
138
- }
139
-
140
- except Exception as exc:
141
-
142
- print("[CRAYON] Generation error:")
143
- print(repr(exc))
144
-
145
- raise HTTPException(
146
- status_code=500,
147
- detail=str(exc),
148
- )
149
-
150
-
151
- if __name__ == "__main__":
152
-
169
+ if __name__=="__main__":
153
170
  import uvicorn
154
-
155
- uvicorn.run(
156
- app,
157
- host=HOST,
158
- port=PORT,
159
- )
171
+ uvicorn.run(app,host=HOST,port=PORT)
package/package.json CHANGED
@@ -1,21 +1,18 @@
1
1
  {
2
2
  "name": "@lazarusrolando/crayoncli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
- "bin": {
6
- "crayon": "dist/index.js"
7
- },
8
5
  "scripts": {
9
6
  "build": "tsc",
10
- "start": "node dist/index.js"
7
+ "start": "node dist/index.js",
8
+ "dev": "tsx src/index.ts"
11
9
  },
12
10
  "dependencies": {
13
- "chalk": "^5.4.1",
14
- "commander": "^14.0.1",
15
- "dotenv": "^17.2.1"
11
+ "dotenv": "^16.4.7"
16
12
  },
17
13
  "devDependencies": {
18
- "@types/node": "^24.0.0",
19
- "typescript": "^5.9.2"
14
+ "@types/node": "^22.10.5",
15
+ "tsx": "^4.19.2",
16
+ "typescript": "^5.7.2"
20
17
  }
21
18
  }
package/src/index.ts CHANGED
@@ -1,52 +1,122 @@
1
- #!/usr/bin/env node
2
1
  import "dotenv/config";
3
- import { Command } from "commander";
4
- import chalk from "chalk";
2
+ import readline from "node:readline";
3
+ import { readFile } from "node:fs/promises";
4
+ import { basename } from "node:path";
5
5
  import { LocalGemma } from "./providers/local-gemma.js";
6
- import { createInterface } from "node:readline/promises";
7
- import { stdin as input, stdout as output } from "node:process";
8
6
 
9
- const program = new Command();
7
+ const gemma=new LocalGemma({
8
+ python:process.env.CRAYON_PYTHON||"python",
9
+ model:process.env.CRAYON_MODEL||"google/gemma-4-E2B-it",
10
+ host:process.env.CRAYON_HOST||"127.0.0.1",
11
+ port:Number(process.env.CRAYON_PORT||8765),
12
+ maxNewTokens:Number(process.env.CRAYON_MAX_NEW_TOKENS||1024),
13
+ temperature:Number(process.env.CRAYON_TEMPERATURE||0.7)
14
+ });
15
+
16
+ async function streamMedia(prompt:string,type:"image"|"audio"|"video",path:string){
17
+ const form=new FormData();
18
+ form.append("prompt",prompt);
19
+ form.append("media_type",type);
20
+ form.append("max_new_tokens",String(Number(process.env.CRAYON_MAX_NEW_TOKENS||1024)));
21
+ form.append("temperature",String(Number(process.env.CRAYON_TEMPERATURE||0.7)));
22
+
23
+ const data=await readFile(path);
24
+ form.append("file",new Blob([data]),basename(path));
10
25
 
11
- program
12
- .name("crayon")
13
- .description("CRAYON — local Crayon-style coding agent")
14
- .argument("[task]");
26
+ const response=await fetch(
27
+ `http://${process.env.CRAYON_HOST||"127.0.0.1"}:${process.env.CRAYON_PORT||8765}/generate/media`,
28
+ {method:"POST",body:form}
29
+ );
15
30
 
16
- program.action(async (task?: string) => {
17
- const gemma = new LocalGemma();
31
+ if(!response.ok) throw new Error(await response.text());
32
+ if(!response.body) throw new Error("No streaming response.");
33
+
34
+ const reader=response.body.getReader();
35
+ const decoder=new TextDecoder();
36
+ process.stdout.write("\nCRAYON: ");
37
+ try{
38
+ while(true){
39
+ const {done,value}=await reader.read();
40
+ if(done) break;
41
+ process.stdout.write(decoder.decode(value,{stream:true}));
42
+ }
43
+ process.stdout.write(decoder.decode());
44
+ process.stdout.write("\n");
45
+ }finally{reader.releaseLock();}
46
+ }
18
47
 
19
- process.on("SIGINT", () => {
48
+ async function main(){
49
+ const args=process.argv.slice(2);
50
+ await gemma.start();
51
+
52
+ // One-shot:
53
+ // crayon "Explain this"
54
+ // crayon image photo.jpg "What is in this image?"
55
+ // crayon audio speech.wav "Transcribe this"
56
+ // crayon video clip.mp4 "Describe this video"
57
+ if(args[0] && ["image","audio","video"].includes(args[0]) && args[1] && args[2]){
58
+ await streamMedia(args.slice(2).join(" "),args[0] as "image"|"audio"|"video",args[1]);
20
59
  gemma.stop();
21
- process.exit(130);
22
- });
60
+ return;
61
+ }
23
62
 
24
- console.log(chalk.cyan("\n╭──────────────────────────────────────────────╮"));
25
- console.log(chalk.cyan("│ CRAYON LOCAL CODER │"));
26
- console.log(chalk.cyan("│ Google Gemma · Local Inference │"));
27
- console.log(chalk.cyan("╰──────────────────────────────────────────────╯\n"));
63
+ if(args.length){
64
+ await gemma.generateStream(args.join(" "));
65
+ gemma.stop();
66
+ return;
67
+ }
68
+
69
+ console.log(`
70
+ ==========================================
71
+ CRAYON v0.4.0 - GEMMA MULTIMODAL CLI
72
+ ==========================================
73
+ Text -> Text
74
+ Image -> Text
75
+ Audio -> Text
76
+ Video -> Text
77
+
78
+ Commands:
79
+ /image <file> <question>
80
+ /audio <file> <question>
81
+ /video <file> <question>
82
+ /exit
83
+
84
+ Examples:
85
+ /image photo.jpg What is in this image?
86
+ /audio speech.wav Transcribe this audio.
87
+ /video clip.mp4 Describe what happens.
88
+ `);
28
89
 
29
- if (!task) {
30
- const rl = createInterface({input, output});
90
+ const rl=readline.createInterface({input:process.stdin,output:process.stdout,prompt:"You: "});
91
+ const shutdown=()=>{rl.close();gemma.stop();process.exit(0);};
92
+ process.on("SIGINT",shutdown);
93
+ rl.prompt();
31
94
 
32
- try {
33
- while (true) {
34
- const q = (await rl.question(chalk.green("> "))).trim();
95
+ rl.on("line",async line=>{
96
+ const input=line.trim();
97
+ if(!input){rl.prompt();return;}
98
+ if(input==="/exit"){shutdown();return;}
99
+ rl.pause();
35
100
 
36
- if (!q || q === "exit" || q === "quit") break;
101
+ try{
102
+ const parts=input.split(/\s+/);
103
+ const cmd=parts[0];
37
104
 
38
- console.log(chalk.dim("Thinking...\n"));
39
- console.log(await gemma.generate(q));
40
- console.log();
105
+ if((cmd==="/image"||cmd==="/audio"||cmd==="/video") && parts.length>=3){
106
+ const type=cmd.slice(1) as "image"|"audio"|"video";
107
+ const path=parts[1];
108
+ const prompt=parts.slice(2).join(" ");
109
+ await streamMedia(prompt,type,path);
110
+ }else{
111
+ await gemma.generateStream(input);
41
112
  }
42
- } finally {
43
- rl.close();
44
- gemma.stop();
113
+ }catch(error){
114
+ console.error("\n[CRAYON ERROR]",error instanceof Error?error.message:error);
115
+ }finally{
116
+ rl.resume();
117
+ rl.prompt();
45
118
  }
46
- } else {
47
- console.log(await gemma.generate(task));
48
- gemma.stop();
49
- }
50
- });
119
+ });
120
+ }
51
121
 
52
- await program.parseAsync();
122
+ main().catch(error=>{console.error("[CRAYON FATAL]",error);gemma.stop();process.exit(1);});
@@ -1,165 +1,75 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
2
- import { setTimeout as delay } from "node:timers/promises";
1
+ import { spawn, ChildProcess } from "node:child_process";
2
+ import { createReadStream } from "node:fs";
3
+ import { basename } from "node:path";
3
4
 
4
5
  export interface GemmaOptions {
5
- python?: string;
6
- host?: string;
7
- port?: number;
8
- model?: string;
6
+ python:string; model:string; host:string; port:number;
7
+ maxNewTokens:number; temperature:number;
9
8
  }
10
9
 
11
10
  export class LocalGemma {
12
- private process?: ChildProcess;
11
+ private process?:ChildProcess;
12
+ private readonly options:GemmaOptions;
13
+ private readonly baseUrl:string;
13
14
 
14
- private readonly python: string;
15
- private readonly host: string;
16
- private readonly port: number;
17
- private readonly model: string;
18
-
19
- constructor(options: GemmaOptions = {}) {
20
- this.python = options.python ?? process.env.CRAYON_PYTHON ?? "python";
21
- this.host = options.host ?? process.env.CRAYON_HOST ?? "127.0.0.1";
22
- this.port = options.port ?? Number(process.env.CRAYON_PORT ?? 8765);
23
- this.model =
24
- options.model ??
25
- process.env.CRAYON_MODEL ??
26
- "google/gemma-4-E2B-it";
27
- }
28
-
29
- private url(path: string): string {
30
- return `http://${this.host}:${this.port}${path}`;
15
+ constructor(options:GemmaOptions){
16
+ this.options=options;
17
+ this.baseUrl=`http://${options.host}:${options.port}`;
31
18
  }
32
19
 
33
- async start(): Promise<void> {
34
- // Check whether a server is already running.
35
- try {
36
- const response = await fetch(this.url("/health"));
20
+ async start():Promise<void>{
21
+ if(await this.isReady()) return;
22
+ console.log("[CRAYON] Starting local Gemma multimodal server...");
23
+ console.log(`[CRAYON] Model: ${this.options.model}`);
37
24
 
38
- if (response.ok) {
39
- return;
40
- }
41
- } catch {
42
- // Server isn't running; start it below.
43
- }
44
-
45
- console.log(`[CRAYON] Starting local Gemma server...`);
46
- console.log(`[CRAYON] Model: ${this.model}`);
47
-
48
- this.process = spawn(
49
- this.python,
50
- ["model/server.py"],
51
- {
52
- stdio: ["ignore", "pipe", "pipe"],
53
- env: {
54
- ...process.env,
55
- CRAYON_MODEL: this.model,
56
- CRAYON_HOST: this.host,
57
- CRAYON_PORT: String(this.port),
58
- },
59
- },
60
- );
61
-
62
- // stdout is available because we configured it as "pipe".
63
- this.process.stdout?.on("data", (data: Buffer) => {
64
- process.stdout.write(
65
- `\x1b[90m[Gemma] ${data.toString()}\x1b[0m`,
66
- );
67
- });
68
-
69
- // stderr is available because we configured it as "pipe".
70
- this.process.stderr?.on("data", (data: Buffer) => {
71
- process.stderr.write(
72
- `\x1b[90m[Gemma] ${data.toString()}\x1b[0m`,
73
- );
74
- });
75
-
76
- this.process.on("error", (error) => {
77
- console.error("[CRAYON] Failed to start Gemma:", error);
78
- });
79
-
80
- this.process.on("exit", (code, signal) => {
81
- if (code !== 0 && code !== null) {
82
- console.error(
83
- `[CRAYON] Gemma server exited with code ${code}`,
84
- );
85
- }
86
-
87
- if (signal) {
88
- console.error(
89
- `[CRAYON] Gemma server stopped by signal ${signal}`,
90
- );
91
- }
25
+ this.process=spawn(this.options.python,["model/server.py"],{
26
+ stdio:["ignore","pipe","pipe"],
27
+ env:{...process.env,CRAYON_MODEL:this.options.model,CRAYON_HOST:this.options.host,
28
+ CRAYON_PORT:String(this.options.port)}
92
29
  });
30
+ this.process.stdout?.on("data",(d:Buffer)=>process.stdout.write(`[Gemma] ${d.toString()}`));
31
+ this.process.stderr?.on("data",(d:Buffer)=>process.stderr.write(`[Gemma] ${d.toString()}`));
93
32
 
94
- // Wait for FastAPI to become available.
95
- for (let attempt = 0; attempt < 180; attempt++) {
96
- try {
97
- const response = await fetch(this.url("/health"));
98
-
99
- if (response.ok) {
100
- console.log("[CRAYON] Gemma server is ready.");
101
- return;
102
- }
103
- } catch {
104
- // Still starting.
105
- }
106
-
107
- await delay(1000);
33
+ const deadline=Date.now()+180000;
34
+ while(Date.now()<deadline){
35
+ if(await this.isReady()){ console.log("[CRAYON] Local multimodal Gemma server is ready."); return; }
36
+ await new Promise(r=>setTimeout(r,1000));
108
37
  }
38
+ throw new Error("Gemma server did not become ready within 180 seconds.");
39
+ }
109
40
 
110
- this.stop();
111
-
112
- throw new Error(
113
- "Gemma server did not become ready within 180 seconds.",
114
- );
41
+ async isReady():Promise<boolean>{
42
+ try{return (await fetch(`${this.baseUrl}/health`)).ok;}catch{return false;}
115
43
  }
116
44
 
117
- async generate(
118
- prompt: string,
119
- maxNewTokens = Number(
120
- process.env.CRAYON_MAX_NEW_TOKENS ?? 1024,
121
- ),
122
- ): Promise<string> {
123
- await this.start();
45
+ private async consumeStream(response:Response):Promise<void>{
46
+ if(!response.ok) throw new Error(`Gemma error (${response.status}): ${await response.text()}`);
47
+ if(!response.body) throw new Error("Gemma returned no stream.");
48
+
49
+ const reader=response.body.getReader();
50
+ const decoder=new TextDecoder();
51
+ process.stdout.write("\nCRAYON: ");
52
+ try{
53
+ while(true){
54
+ const {done,value}=await reader.read();
55
+ if(done) break;
56
+ process.stdout.write(decoder.decode(value,{stream:true}));
57
+ }
58
+ process.stdout.write(decoder.decode());
59
+ process.stdout.write("\n");
60
+ }finally{reader.releaseLock();}
61
+ }
124
62
 
125
- const response = await fetch(this.url("/generate"), {
126
- method: "POST",
127
- headers: {
128
- "content-type": "application/json",
129
- },
130
- body: JSON.stringify({
131
- prompt,
132
- max_new_tokens: maxNewTokens,
133
- temperature: 0.2,
134
- top_p: 0.9,
135
- }),
63
+ async generateStream(prompt:string):Promise<void>{
64
+ const response=await fetch(`${this.baseUrl}/generate/stream`,{
65
+ method:"POST",headers:{"Content-Type":"application/json"},
66
+ body:JSON.stringify({prompt,max_new_tokens:this.options.maxNewTokens,
67
+ temperature:this.options.temperature})
136
68
  });
137
-
138
- if (!response.ok) {
139
- const errorText = await response.text();
140
-
141
- throw new Error(
142
- `Gemma server returned HTTP ${response.status}: ${errorText}`,
143
- );
144
- }
145
-
146
- const result = (await response.json()) as {
147
- text: string;
148
- model?: string;
149
- };
150
-
151
- return result.text;
69
+ await this.consumeStream(response);
152
70
  }
153
71
 
154
- stop(): void {
155
- if (!this.process) {
156
- return;
157
- }
158
-
159
- if (!this.process.killed) {
160
- this.process.kill();
161
- }
162
-
163
- this.process = undefined;
72
+ stop():void{
73
+ if(this.process && !this.process.killed) this.process.kill();
164
74
  }
165
75
  }
package/tsconfig.json CHANGED
@@ -1,15 +1,5 @@
1
1
  {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "outDir": "dist",
7
- "rootDir": "src",
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true
11
- },
12
- "include": [
13
- "src"
14
- ]
2
+ "compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext",
3
+ "outDir":"dist","rootDir":"src","strict":true,"esModuleInterop":true,"skipLibCheck":true},
4
+ "include":["src/**/*.ts"]
15
5
  }
@@ -1,16 +0,0 @@
1
- #!/bin/sh
2
- basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
-
4
- case `uname` in
5
- *CYGWIN*|*MINGW*|*MSYS*)
6
- if command -v cygpath > /dev/null 2>&1; then
7
- basedir=`cygpath -w "$basedir"`
8
- fi
9
- ;;
10
- esac
11
-
12
- if [ -x "$basedir/node" ]; then
13
- exec "$basedir/node" "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" "$@"
14
- else
15
- exec node "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" "$@"
16
- fi
@@ -1,17 +0,0 @@
1
- @ECHO off
2
- GOTO start
3
- :find_dp0
4
- SET dp0=%~dp0
5
- EXIT /b
6
- :start
7
- SETLOCAL
8
- CALL :find_dp0
9
-
10
- IF EXIST "%dp0%\node.exe" (
11
- SET "_prog=%dp0%\node.exe"
12
- ) ELSE (
13
- SET "_prog=node"
14
- SET PATHEXT=%PATHEXT:;.JS;=;%
15
- )
16
-
17
- endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\Crayon-cli-local-gemma\dist\index.js" %*
@@ -1,28 +0,0 @@
1
- #!/usr/bin/env pwsh
2
- $basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
3
-
4
- $exe=""
5
- if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
6
- # Fix case when both the Windows and Linux builds of Node
7
- # are installed in the same directory
8
- $exe=".exe"
9
- }
10
- $ret=0
11
- if (Test-Path "$basedir/node$exe") {
12
- # Support pipeline input
13
- if ($MyInvocation.ExpectingInput) {
14
- $input | & "$basedir/node$exe" "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" $args
15
- } else {
16
- & "$basedir/node$exe" "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" $args
17
- }
18
- $ret=$LASTEXITCODE
19
- } else {
20
- # Support pipeline input
21
- if ($MyInvocation.ExpectingInput) {
22
- $input | & "node$exe" "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" $args
23
- } else {
24
- & "node$exe" "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" $args
25
- }
26
- $ret=$LASTEXITCODE
27
- }
28
- exit $ret