@lazarusrolando/crayoncli 0.2.0 → 0.3.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,40 @@
1
- # CRAYON v0.2.0 — Local Gemma CLI
1
+ # CRAYON v0.3.0 — Local Gemma Streaming
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
+ Real local token streaming for the CRAYON CLI.
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
+ ## Flow
6
+
7
+ You → Node.js CLI → local FastAPI → Transformers TextIteratorStreamer → local Gemma → tokens → CLI
8
+
9
+ Hugging Face is only the model repository. Inference is local.
10
10
 
11
11
  ## Install
12
12
 
13
13
  ```powershell
14
14
  npm install
15
- python -m pip install -r model/requirements.txt
15
+ python -m pip install -r model\requirements.txt
16
16
  Copy-Item .env.example .env
17
17
  npm run build
18
- npm link
19
18
  ```
20
19
 
21
20
  ## Run
22
21
 
23
22
  ```powershell
24
- crayon
25
- crayon "explain this project"
23
+ node dist/index.js
26
24
  ```
27
25
 
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.
26
+ or:
29
27
 
30
- ## Configuration
31
-
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
28
+ ```powershell
29
+ node dist/index.js "Explain recursion in JavaScript"
38
30
  ```
31
+
32
+ ## What v0.3 adds
33
+
34
+ - `TextIteratorStreamer` for local token streaming
35
+ - background generation thread
36
+ - FastAPI `StreamingResponse`
37
+ - Node.js `ReadableStream` consumption
38
+ - keeps `/generate` for non-streaming compatibility
39
+
40
+ Streaming changes delivery timing, not the inference provider. The model still runs locally.
package/dist/index.js CHANGED
@@ -1,45 +1,54 @@
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";
5
3
  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", () => {
4
+ const gemma = new LocalGemma({
5
+ python: process.env.CRAYON_PYTHON || "python",
6
+ model: process.env.CRAYON_MODEL || "google/gemma-4-E2B-it",
7
+ host: process.env.CRAYON_HOST || "127.0.0.1",
8
+ port: Number(process.env.CRAYON_PORT || 8765),
9
+ maxNewTokens: Number(process.env.CRAYON_MAX_NEW_TOKENS || 1024),
10
+ temperature: Number(process.env.CRAYON_TEMPERATURE || 0.7)
11
+ });
12
+ async function main() {
13
+ const oneShot = process.argv.slice(2).join(" ").trim();
14
+ await gemma.start();
15
+ if (oneShot) {
16
+ await gemma.generateStream(oneShot);
16
17
  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 });
18
+ return;
19
+ }
20
+ console.log("\n==========================================");
21
+ console.log(" CRAYON v0.3.0 - LOCAL STREAMING");
22
+ console.log("==========================================");
23
+ console.log("Gemma inference: LOCAL");
24
+ console.log("Hugging Face inference API: NOT USED");
25
+ console.log("Streaming: ENABLED\n");
26
+ console.log("Type /exit to quit.\n");
27
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "You: " });
28
+ const shutdown = () => { rl.close(); gemma.stop(); process.exit(0); };
29
+ process.on("SIGINT", shutdown);
30
+ rl.prompt();
31
+ rl.on("line", async (line) => {
32
+ const prompt = line.trim();
33
+ if (!prompt) {
34
+ rl.prompt();
35
+ return;
36
+ }
37
+ if (prompt === "/exit") {
38
+ shutdown();
39
+ return;
40
+ }
41
+ rl.pause();
25
42
  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();
33
- }
43
+ await gemma.generateStream(prompt);
44
+ }
45
+ catch (error) {
46
+ console.error("\n[CRAYON ERROR]", error instanceof Error ? error.message : error);
34
47
  }
35
48
  finally {
36
- rl.close();
37
- gemma.stop();
49
+ rl.resume();
50
+ rl.prompt();
38
51
  }
39
- }
40
- else {
41
- console.log(await gemma.generate(task));
42
- gemma.stop();
43
- }
44
- });
45
- await program.parseAsync();
52
+ });
53
+ }
54
+ main().catch(error => { console.error("[CRAYON FATAL]", error); gemma.stop(); process.exit(1); });
@@ -1,109 +1,79 @@
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 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 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
- }
39
+ }
40
+ async generateStream(prompt) {
41
+ const response = await fetch(`${this.baseUrl}/generate/stream`, {
42
+ method: "POST", headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify({ prompt, max_new_tokens: this.options.maxNewTokens, temperature: this.options.temperature })
61
44
  });
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.
45
+ if (!response.ok)
46
+ throw new Error(`Gemma streaming error (${response.status}): ${await response.text()}`);
47
+ if (!response.body)
48
+ throw new Error("Gemma returned no streaming body.");
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)
56
+ break;
57
+ process.stdout.write(decoder.decode(value, { stream: true }));
73
58
  }
74
- await delay(1000);
59
+ process.stdout.write(decoder.decode());
60
+ process.stdout.write("\n");
61
+ }
62
+ finally {
63
+ reader.releaseLock();
75
64
  }
76
- this.stop();
77
- throw new Error("Gemma server did not become ready within 180 seconds.");
78
65
  }
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
- }),
66
+ async generate(prompt) {
67
+ const response = await fetch(`${this.baseUrl}/generate`, {
68
+ method: "POST", headers: { "Content-Type": "application/json" },
69
+ body: JSON.stringify({ prompt, max_new_tokens: this.options.maxNewTokens, temperature: this.options.temperature })
92
70
  });
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;
71
+ if (!response.ok)
72
+ throw new Error(`Gemma error (${response.status}): ${await response.text()}`);
73
+ return (await response.json()).text;
99
74
  }
100
75
  stop() {
101
- if (!this.process) {
102
- return;
103
- }
104
- if (!this.process.killed) {
76
+ if (this.process && !this.process.killed)
105
77
  this.process.kill();
106
- }
107
- this.process = undefined;
108
78
  }
109
79
  }
@@ -1,6 +1,6 @@
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
package/model/server.py CHANGED
@@ -1,159 +1,113 @@
1
1
  import os
2
+ from threading import Thread
2
3
  import torch
3
- from fastapi import FastAPI, HTTPException
4
+ from fastapi import FastAPI
5
+ from fastapi.responses import StreamingResponse
4
6
  from pydantic import BaseModel
5
- from transformers import AutoTokenizer, AutoModelForCausalLM
7
+ from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
6
8
 
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...")
23
-
24
- tokenizer = AutoTokenizer.from_pretrained(
25
- MODEL_ID
26
- )
27
-
28
- print("[CRAYON] Tokenizer loaded.")
29
- print("[CRAYON] Loading model...")
30
-
31
- model = AutoModelForCausalLM.from_pretrained(
32
- MODEL_ID,
33
- device_map="auto",
34
- torch_dtype="auto",
35
- )
9
+ MODEL_ID=os.getenv("CRAYON_MODEL","google/gemma-4-E2B-it")
10
+ HOST=os.getenv("CRAYON_HOST","127.0.0.1")
11
+ PORT=int(os.getenv("CRAYON_PORT","8765"))
36
12
 
13
+ print(f"[CRAYON MODEL] Loading: {MODEL_ID}",flush=True)
14
+ print(f"[CRAYON MODEL] CUDA available: {torch.cuda.is_available()}",flush=True)
15
+ tokenizer=AutoTokenizer.from_pretrained(MODEL_ID)
16
+ model=AutoModelForCausalLM.from_pretrained(MODEL_ID,device_map="auto",torch_dtype="auto")
37
17
  model.eval()
18
+ print("[CRAYON MODEL] Model loaded.",flush=True)
19
+ print(f"[CRAYON MODEL] Device: {model.device}",flush=True)
38
20
 
39
- print("[CRAYON] Model loaded successfully.")
40
-
41
- app = FastAPI(
42
- title="CRAYON Local Gemma Server",
43
- version="0.2.1",
44
- )
45
-
21
+ app=FastAPI(title="CRAYON Local Gemma Server",version="0.3.0")
46
22
 
47
23
  class GenerateRequest(BaseModel):
48
- prompt: str
49
- max_new_tokens: int = 1024
50
- temperature: float = 0.2
51
- top_p: float = 0.9
52
-
24
+ prompt:str
25
+ max_new_tokens:int=1024
26
+ temperature:float=0.7
53
27
 
54
28
  @app.get("/health")
55
29
  def health():
56
- return {
57
- "ok": True,
58
- "model": MODEL_ID,
59
- "cuda": torch.cuda.is_available(),
60
- "device": str(model.device),
61
- }
62
-
63
-
64
- @app.post("/generate")
65
- def generate(req: GenerateRequest):
66
-
67
- 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]}"
98
- )
99
-
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
30
+ return {"status":"ok","model":MODEL_ID,"device":str(model.device),
31
+ "cuda":torch.cuda.is_available(),"streaming":True,"inference":"local"}
32
+
33
+ def build_inputs(prompt):
34
+ messages=[{"role":"user","content":prompt}]
35
+ inputs=tokenizer.apply_chat_template(messages,tokenize=True,add_generation_prompt=True,
36
+ return_tensors="pt",return_dict=True)
37
+ input_ids=inputs["input_ids"].to(model.device)
38
+ attention_mask=inputs.get("attention_mask")
39
+ if attention_mask is not None:
40
+ attention_mask=attention_mask.to(model.device)
41
+ return input_ids,attention_mask
42
+
43
+ @app.post("/generate/stream")
44
+ def generate_stream(request:GenerateRequest):
45
+ input_ids,attention_mask=build_inputs(request.prompt)
46
+ streamer=TextIteratorStreamer(tokenizer,skip_prompt=True,skip_special_tokens=True)
47
+ kwargs={"input_ids":input_ids,"streamer":streamer,
48
+ "max_new_tokens":request.max_new_tokens,
49
+ "do_sample":request.temperature>0}
50
+ if attention_mask is not None: kwargs["attention_mask"]=attention_mask
51
+ if request.temperature>0: kwargs["temperature"]=request.temperature
52
+
53
+ worker=Thread(target=model.generate,kwargs=kwargs,daemon=True)
54
+ worker.start()
55
+
56
+ def token_iterator():
57
+ generated_text = ""
58
+ chunk_count = 0
59
+
60
+ try:
61
+ for text in streamer:
62
+ chunk_count += 1
63
+ generated_text += text
64
+
65
+ # TextIteratorStreamer emits decoded text chunks. Re-tokenize
66
+ # accumulated output to report the generated-token count.
67
+ token_count = len(
68
+ tokenizer.encode(
69
+ generated_text,
70
+ add_special_tokens=False,
71
+ )
72
+ )
73
+
74
+ print(
75
+ f"[CRAYON TOKEN] #{token_count}: {text!r}",
76
+ flush=True,
77
+ )
78
+
79
+ yield text
80
+
81
+ final_token_count = len(
82
+ tokenizer.encode(
83
+ generated_text,
84
+ add_special_tokens=False,
85
+ )
118
86
  )
119
87
 
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
-
88
+ print(
89
+ f"[CRAYON STREAM] Generation complete | "
90
+ f"chunks={chunk_count} | "
91
+ f"generated_tokens={final_token_count}",
92
+ flush=True,
93
+ )
94
+ finally:
95
+ worker.join()
150
96
 
151
- if __name__ == "__main__":
97
+ return StreamingResponse(token_iterator(),media_type="text/plain; charset=utf-8",
98
+ headers={"Cache-Control":"no-cache","X-CRAYON-Streaming":"true"})
152
99
 
100
+ @app.post("/generate")
101
+ def generate(request:GenerateRequest):
102
+ input_ids,attention_mask=build_inputs(request.prompt)
103
+ kwargs={"input_ids":input_ids,"max_new_tokens":request.max_new_tokens,
104
+ "do_sample":request.temperature>0}
105
+ if attention_mask is not None: kwargs["attention_mask"]=attention_mask
106
+ if request.temperature>0: kwargs["temperature"]=request.temperature
107
+ output=model.generate(**kwargs)
108
+ new_tokens=output[0][input_ids.shape[-1]:]
109
+ return {"text":tokenizer.decode(new_tokens,skip_special_tokens=True)}
110
+
111
+ if __name__=="__main__":
153
112
  import uvicorn
154
-
155
- uvicorn.run(
156
- app,
157
- host=HOST,
158
- port=PORT,
159
- )
113
+ 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.3.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,43 @@
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";
5
3
  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
4
 
9
- const program = new Command();
10
-
11
- program
12
- .name("crayon")
13
- .description("CRAYON — local Crayon-style coding agent")
14
- .argument("[task]");
15
-
16
- program.action(async (task?: string) => {
17
- const gemma = new LocalGemma();
18
-
19
- process.on("SIGINT", () => {
20
- gemma.stop();
21
- process.exit(130);
22
- });
23
-
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"));
28
-
29
- if (!task) {
30
- const rl = createInterface({input, output});
31
-
32
- try {
33
- while (true) {
34
- const q = (await rl.question(chalk.green("> "))).trim();
35
-
36
- if (!q || q === "exit" || q === "quit") break;
37
-
38
- console.log(chalk.dim("Thinking...\n"));
39
- console.log(await gemma.generate(q));
40
- console.log();
41
- }
42
- } finally {
43
- rl.close();
44
- gemma.stop();
45
- }
46
- } else {
47
- console.log(await gemma.generate(task));
48
- gemma.stop();
49
- }
5
+ const gemma=new LocalGemma({
6
+ python:process.env.CRAYON_PYTHON||"python",
7
+ model:process.env.CRAYON_MODEL||"google/gemma-4-E2B-it",
8
+ host:process.env.CRAYON_HOST||"127.0.0.1",
9
+ port:Number(process.env.CRAYON_PORT||8765),
10
+ maxNewTokens:Number(process.env.CRAYON_MAX_NEW_TOKENS||1024),
11
+ temperature:Number(process.env.CRAYON_TEMPERATURE||0.7)
50
12
  });
51
13
 
52
- await program.parseAsync();
14
+ async function main(){
15
+ const oneShot=process.argv.slice(2).join(" ").trim();
16
+ await gemma.start();
17
+
18
+ if(oneShot){ await gemma.generateStream(oneShot); gemma.stop(); return; }
19
+
20
+ console.log("\n==========================================");
21
+ console.log(" CRAYON v0.3.0 - LOCAL STREAMING");
22
+ console.log("==========================================");
23
+ console.log("Gemma inference: LOCAL");
24
+ console.log("Hugging Face inference API: NOT USED");
25
+ console.log("Streaming: ENABLED\n");
26
+ console.log("Type /exit to quit.\n");
27
+
28
+ const rl=readline.createInterface({input:process.stdin,output:process.stdout,prompt:"You: "});
29
+ const shutdown=()=>{rl.close();gemma.stop();process.exit(0);};
30
+ process.on("SIGINT",shutdown);
31
+ rl.prompt();
32
+
33
+ rl.on("line",async line=>{
34
+ const prompt=line.trim();
35
+ if(!prompt){rl.prompt();return;}
36
+ if(prompt==="/exit"){shutdown();return;}
37
+ rl.pause();
38
+ try{await gemma.generateStream(prompt);}
39
+ catch(error){console.error("\n[CRAYON ERROR]",error instanceof Error?error.message:error);}
40
+ finally{rl.resume();rl.prompt();}
41
+ });
42
+ }
43
+ main().catch(error=>{console.error("[CRAYON FATAL]",error);gemma.stop();process.exit(1);});
@@ -1,165 +1,78 @@
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";
3
2
 
4
3
  export interface GemmaOptions {
5
- python?: string;
6
- host?: string;
7
- port?: number;
8
- model?: string;
4
+ python:string; model:string; host:string; port:number;
5
+ maxNewTokens:number; temperature:number;
9
6
  }
10
7
 
11
8
  export class LocalGemma {
12
- private process?: ChildProcess;
9
+ private process?:ChildProcess;
10
+ private readonly options:GemmaOptions;
11
+ private readonly baseUrl:string;
13
12
 
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}`;
13
+ constructor(options:GemmaOptions){
14
+ this.options=options;
15
+ this.baseUrl=`http://${options.host}:${options.port}`;
31
16
  }
32
17
 
33
- async start(): Promise<void> {
34
- // Check whether a server is already running.
35
- try {
36
- const response = await fetch(this.url("/health"));
37
-
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
- );
18
+ async start():Promise<void>{
19
+ if(await this.isReady()) return;
20
+ console.log("[CRAYON] Starting local Gemma server...");
21
+ console.log(`[CRAYON] Model: ${this.options.model}`);
61
22
 
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
- );
23
+ this.process=spawn(this.options.python,["model/server.py"],{
24
+ stdio:["ignore","pipe","pipe"],
25
+ env:{...process.env,CRAYON_MODEL:this.options.model,CRAYON_HOST:this.options.host,
26
+ CRAYON_PORT:String(this.options.port)}
67
27
  });
68
28
 
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
- });
29
+ this.process.stdout?.on("data",(d:Buffer)=>process.stdout.write(`[Gemma] ${d.toString()}`));
30
+ this.process.stderr?.on("data",(d:Buffer)=>process.stderr.write(`[Gemma] ${d.toString()}`));
75
31
 
76
- this.process.on("error", (error) => {
77
- console.error("[CRAYON] Failed to start Gemma:", error);
78
- });
32
+ const deadline=Date.now()+180000;
33
+ while(Date.now()<deadline){
34
+ if(await this.isReady()){ console.log("[CRAYON] Local Gemma server is ready."); return; }
35
+ await new Promise(r=>setTimeout(r,1000));
36
+ }
37
+ throw new Error("Gemma server did not become ready within 180 seconds.");
38
+ }
79
39
 
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
- }
40
+ async isReady():Promise<boolean>{
41
+ try{return (await fetch(`${this.baseUrl}/health`)).ok;}catch{return false;}
42
+ }
86
43
 
87
- if (signal) {
88
- console.error(
89
- `[CRAYON] Gemma server stopped by signal ${signal}`,
90
- );
91
- }
44
+ async generateStream(prompt:string):Promise<void>{
45
+ const response=await fetch(`${this.baseUrl}/generate/stream`,{
46
+ method:"POST",headers:{"Content-Type":"application/json"},
47
+ body:JSON.stringify({prompt,max_new_tokens:this.options.maxNewTokens,temperature:this.options.temperature})
92
48
  });
93
-
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.
49
+ if(!response.ok) throw new Error(`Gemma streaming error (${response.status}): ${await response.text()}`);
50
+ if(!response.body) throw new Error("Gemma returned no streaming body.");
51
+
52
+ const reader=response.body.getReader();
53
+ const decoder=new TextDecoder();
54
+ process.stdout.write("\nCRAYON: ");
55
+ try{
56
+ while(true){
57
+ const {done,value}=await reader.read();
58
+ if(done) break;
59
+ process.stdout.write(decoder.decode(value,{stream:true}));
105
60
  }
106
-
107
- await delay(1000);
108
- }
109
-
110
- this.stop();
111
-
112
- throw new Error(
113
- "Gemma server did not become ready within 180 seconds.",
114
- );
61
+ process.stdout.write(decoder.decode());
62
+ process.stdout.write("\n");
63
+ }finally{reader.releaseLock();}
115
64
  }
116
65
 
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();
124
-
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
- }),
66
+ async generate(prompt:string):Promise<string>{
67
+ const response=await fetch(`${this.baseUrl}/generate`,{
68
+ method:"POST",headers:{"Content-Type":"application/json"},
69
+ body:JSON.stringify({prompt,max_new_tokens:this.options.maxNewTokens,temperature:this.options.temperature})
136
70
  });
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;
71
+ if(!response.ok) throw new Error(`Gemma error (${response.status}): ${await response.text()}`);
72
+ return ((await response.json()) as {text:string}).text;
152
73
  }
153
74
 
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;
75
+ stop():void{
76
+ if(this.process && !this.process.killed) this.process.kill();
164
77
  }
165
- }
78
+ }
package/tsconfig.json CHANGED
@@ -1,15 +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
- ]
15
- }
1
+ {"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","outDir":"dist","rootDir":"src","strict":true,"esModuleInterop":true,"skipLibCheck":true},"include":["src/**/*.ts"]}
@@ -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