z0gcode 0.2.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 +41 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/z0g.mjs +1052 -0
- package/contracts/Z0gSession.json +424 -0
- package/contracts/Z0gSession.sol +106 -0
- package/package.json +60 -0
- package/skills/0g/CHAIN.md +229 -0
- package/skills/0g/COMPUTE.md +296 -0
- package/skills/0g/NETWORK_CONFIG.md +163 -0
- package/skills/0g/SECURITY.md +174 -0
- package/skills/0g/STORAGE.md +167 -0
- package/skills/0g/TESTING.md +263 -0
- package/src/agent.mjs +434 -0
- package/src/anchor.mjs +65 -0
- package/src/checkpoints.mjs +64 -0
- package/src/client.mjs +134 -0
- package/src/commands.mjs +93 -0
- package/src/config.mjs +74 -0
- package/src/context.mjs +55 -0
- package/src/crypto.mjs +47 -0
- package/src/env.mjs +47 -0
- package/src/goal.mjs +45 -0
- package/src/inft.mjs +79 -0
- package/src/mcp-server.mjs +38 -0
- package/src/mcp.mjs +91 -0
- package/src/media.mjs +52 -0
- package/src/models-info.mjs +97 -0
- package/src/plan.mjs +21 -0
- package/src/prompt.mjs +149 -0
- package/src/provenance.mjs +46 -0
- package/src/sessions.mjs +168 -0
- package/src/settings.mjs +37 -0
- package/src/skills.mjs +43 -0
- package/src/tools.mjs +481 -0
- package/src/ui.mjs +798 -0
- package/src/user-skills.mjs +111 -0
- package/src/worktree.mjs +61 -0
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// MCP client: connect to configured MCP servers (.z0g/mcp.json) and expose their
|
|
2
|
+
// tools to the agent, so z0gcode becomes a hub for 0G and third-party MCP tools.
|
|
3
|
+
// The SDK is an optional dependency: if it or the config is missing, MCP is skipped.
|
|
4
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const CONFIG = (cwd) => path.join(cwd, ".z0g", "mcp.json");
|
|
8
|
+
const PREFIX = "mcp_";
|
|
9
|
+
|
|
10
|
+
const sanitize = (s) => String(s).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48);
|
|
11
|
+
|
|
12
|
+
// Returns null (MCP disabled) or { tools, isMcp, call, close, count }.
|
|
13
|
+
export async function loadMcp(cwd) {
|
|
14
|
+
const cfgPath = CONFIG(cwd);
|
|
15
|
+
if (!existsSync(cfgPath)) return null;
|
|
16
|
+
let cfg;
|
|
17
|
+
try {
|
|
18
|
+
cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const servers = cfg.servers || cfg.mcpServers || {};
|
|
23
|
+
if (!Object.keys(servers).length) return null;
|
|
24
|
+
|
|
25
|
+
let Client, StdioClientTransport;
|
|
26
|
+
try {
|
|
27
|
+
({ Client } = await import("@modelcontextprotocol/sdk/client/index.js"));
|
|
28
|
+
({ StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js"));
|
|
29
|
+
} catch {
|
|
30
|
+
return null; // SDK not installed
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const routes = {}; // agentToolName -> { client, remote }
|
|
34
|
+
const tools = [];
|
|
35
|
+
const conns = [];
|
|
36
|
+
|
|
37
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
38
|
+
if (!spec || !spec.command) continue;
|
|
39
|
+
try {
|
|
40
|
+
const transport = new StdioClientTransport({
|
|
41
|
+
command: spec.command,
|
|
42
|
+
args: spec.args || [],
|
|
43
|
+
env: { ...process.env, ...(spec.env || {}) },
|
|
44
|
+
});
|
|
45
|
+
const client = new Client({ name: "z0gcode", version: "0.2.0" }, { capabilities: {} });
|
|
46
|
+
await client.connect(transport);
|
|
47
|
+
conns.push(client);
|
|
48
|
+
const res = await client.listTools();
|
|
49
|
+
for (const t of res.tools || []) {
|
|
50
|
+
const toolName = `${PREFIX}${sanitize(name)}__${sanitize(t.name)}`;
|
|
51
|
+
routes[toolName] = { client, remote: t.name };
|
|
52
|
+
tools.push({
|
|
53
|
+
type: "function",
|
|
54
|
+
function: {
|
|
55
|
+
name: toolName,
|
|
56
|
+
description: `[MCP:${name}] ${t.description || t.name}`,
|
|
57
|
+
parameters: t.inputSchema && t.inputSchema.type ? t.inputSchema : { type: "object", properties: {} },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// skip a server that fails to start/connect
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
tools,
|
|
68
|
+
count: tools.length,
|
|
69
|
+
isMcp: (name) => !!routes[name],
|
|
70
|
+
async call(name, args) {
|
|
71
|
+
const r = routes[name];
|
|
72
|
+
if (!r) return { ok: false, summary: `mcp ${name} not found`, content: "unknown MCP tool" };
|
|
73
|
+
try {
|
|
74
|
+
const out = await r.client.callTool({ name: r.remote, arguments: args || {} });
|
|
75
|
+
const text = (out.content || [])
|
|
76
|
+
.map((x) => (x.type === "text" ? x.text : JSON.stringify(x)))
|
|
77
|
+
.join("\n");
|
|
78
|
+
return { ok: !out.isError, summary: `mcp ${name}`, content: text || "OK" };
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { ok: false, summary: `mcp ${name} error`, content: `ERROR: ${e.message}` };
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
async close() {
|
|
84
|
+
for (const c of conns) {
|
|
85
|
+
try {
|
|
86
|
+
await c.close();
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/media.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Media on the 0G Router: image generation (z-image-turbo, priced per image)
|
|
2
|
+
// and speech-to-text (whisper-large-v3, priced per second). Same OpenAI-
|
|
3
|
+
// compatible endpoints and 0G API key. Each call also returns its cost.
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { CONFIG } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
async function pricingOf(client, modelId) {
|
|
8
|
+
try {
|
|
9
|
+
const res = await client.models.list();
|
|
10
|
+
return (res.data || []).find((m) => m.id === modelId)?.pricing_usd || null;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Generate images. Returns { images: base64 PNG[], cost }. n clamped to 1..2.
|
|
17
|
+
export async function generateImage(client, { prompt, n = 1 }) {
|
|
18
|
+
const count = Math.max(1, Math.min(2, Number(n) || 1));
|
|
19
|
+
const res = await client.images.generate({ model: CONFIG.imageModel, prompt, n: count });
|
|
20
|
+
const images = (res?.data || []).map((d) => d.b64_json).filter(Boolean);
|
|
21
|
+
if (!images.length) throw new Error("no image data returned (expected base64)");
|
|
22
|
+
const pu = await pricingOf(client, CONFIG.imageModel);
|
|
23
|
+
const per = Number(pu?.image ?? pu?.prompt ?? 0);
|
|
24
|
+
const cost = per > 0 ? per * images.length : null;
|
|
25
|
+
return { images, cost };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Transcribe an audio file. Returns { text, duration, cost }.
|
|
29
|
+
export async function transcribeAudio(client, filePath) {
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await client.audio.transcriptions.create({
|
|
33
|
+
file: createReadStream(filePath),
|
|
34
|
+
model: CONFIG.transcribeModel,
|
|
35
|
+
response_format: "verbose_json",
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
res = await client.audio.transcriptions.create({
|
|
39
|
+
file: createReadStream(filePath),
|
|
40
|
+
model: CONFIG.transcribeModel,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const text = (typeof res === "string" ? res : res?.text ?? "").trim();
|
|
44
|
+
const duration = Number(res?.duration) || null;
|
|
45
|
+
let cost = null;
|
|
46
|
+
if (duration) {
|
|
47
|
+
const pu = await pricingOf(client, CONFIG.transcribeModel);
|
|
48
|
+
const per = Number(pu?.prompt ?? 0);
|
|
49
|
+
if (per > 0) cost = per * duration;
|
|
50
|
+
}
|
|
51
|
+
return { text, duration, cost };
|
|
52
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Model catalog: fetch + normalize the 0G Router's GET /v1/models into the
|
|
2
|
+
// fields the CLI renders (price, context, capabilities, TEE, discount).
|
|
3
|
+
// All display data comes from the API; only the "vs official" discount is a
|
|
4
|
+
// bundled reference (the Router API does not expose it).
|
|
5
|
+
|
|
6
|
+
// "vs official API price" savings, from the 0G pricing page. Reference only:
|
|
7
|
+
// the live price column always comes from the API, so if these drift the
|
|
8
|
+
// numbers a user actually pays stay correct.
|
|
9
|
+
export const DISCOUNTS = {
|
|
10
|
+
"minimax-m3": 55,
|
|
11
|
+
"0gm-1.0-35b-a3b": 50,
|
|
12
|
+
"qwen3.7-max": 60,
|
|
13
|
+
"qwen3.6-plus": 50,
|
|
14
|
+
"qwen3.7-plus": 45,
|
|
15
|
+
"glm-5": 40,
|
|
16
|
+
"glm-5.1": 35,
|
|
17
|
+
"glm-5.2": 30,
|
|
18
|
+
"kimi-k2.7-code": 18,
|
|
19
|
+
"deepseek-v4-pro": 15,
|
|
20
|
+
"deepseek-v4-flash": 12,
|
|
21
|
+
"claude-fable-5": 10,
|
|
22
|
+
"claude-opus-4-8": 10,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Binary K, decimal M: 262144 -> "256K", 32768 -> "32K", 131072 -> "128K",
|
|
26
|
+
// 1000000 -> "1M", 1048576 -> "1.0M" (the values developers recognize).
|
|
27
|
+
export function fmtCtx(n) {
|
|
28
|
+
if (!n || n <= 0) return "?";
|
|
29
|
+
if (n >= 1_000_000) {
|
|
30
|
+
const v = n / 1_000_000;
|
|
31
|
+
return (Math.abs(v - 1) < 1e-9 ? "1" : v.toFixed(1)) + "M";
|
|
32
|
+
}
|
|
33
|
+
return Math.round(n / 1024) + "K";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// $/1M tokens, adaptive precision so tiny prices keep meaning.
|
|
37
|
+
export function fmtPrice(perM) {
|
|
38
|
+
if (perM == null) return "-";
|
|
39
|
+
if (perM >= 100) return "$" + perM.toFixed(0);
|
|
40
|
+
if (perM >= 1) return "$" + perM.toFixed(2);
|
|
41
|
+
return "$" + perM.toFixed(perM < 0.1 ? 4 : 3);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalize(m) {
|
|
45
|
+
const im = m.architecture?.input_modalities || [];
|
|
46
|
+
const sp = m.supported_parameters || [];
|
|
47
|
+
const inUsd = Number(m.pricing_usd?.prompt);
|
|
48
|
+
const outUsd = Number(m.pricing_usd?.completion);
|
|
49
|
+
return {
|
|
50
|
+
id: m.id,
|
|
51
|
+
name: m.name || m.id,
|
|
52
|
+
description: m.description || "",
|
|
53
|
+
type: m.type || "chatbot",
|
|
54
|
+
ctx: m.context_length || null,
|
|
55
|
+
maxOut: m.max_completion_tokens || null,
|
|
56
|
+
inPerM: Number.isFinite(inUsd) && inUsd > 0 ? inUsd * 1e6 : null,
|
|
57
|
+
outPerM: Number.isFinite(outUsd) && outUsd > 0 ? outUsd * 1e6 : null,
|
|
58
|
+
tools: sp.includes("tools"),
|
|
59
|
+
vision: im.includes("image"),
|
|
60
|
+
verifiable: !!m.tee_attested && !!m.verifiability && m.verifiability !== "None",
|
|
61
|
+
private: m.verifiability === "TeeML",
|
|
62
|
+
tee: m.tee_type || null,
|
|
63
|
+
discount: DISCOUNTS[m.id] ?? null,
|
|
64
|
+
raw: m,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fetch and normalize. Returns [] on failure is NOT desired; let caller catch.
|
|
69
|
+
export async function fetchModels(client) {
|
|
70
|
+
const res = await client.models.list();
|
|
71
|
+
return (res.data || []).map(normalize);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Chat/coding models, ordered: default first, then verifiable+tools by input
|
|
75
|
+
// price ascending, then the rest, then non-verifiable (e.g. proxied Claude).
|
|
76
|
+
export function orderChatModels(models, defaultId) {
|
|
77
|
+
const chat = models.filter((m) => m.type === "chatbot");
|
|
78
|
+
const rank = (m) => {
|
|
79
|
+
if (m.id === defaultId) return 0;
|
|
80
|
+
if (m.verifiable && m.tools) return 1;
|
|
81
|
+
if (m.verifiable) return 2;
|
|
82
|
+
return 3;
|
|
83
|
+
};
|
|
84
|
+
return chat.sort((a, b) => {
|
|
85
|
+
const ra = rank(a);
|
|
86
|
+
const rb = rank(b);
|
|
87
|
+
if (ra !== rb) return ra - rb;
|
|
88
|
+
const pa = a.inPerM ?? Infinity;
|
|
89
|
+
const pb = b.inPerM ?? Infinity;
|
|
90
|
+
return pa - pb;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Non-chat models (speech, image) for a separate compact section.
|
|
95
|
+
export function mediaModels(models) {
|
|
96
|
+
return models.filter((m) => m.type !== "chatbot");
|
|
97
|
+
}
|
package/src/plan.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Plan/checklist: the agent maintains a visible todo list for multi-step tasks.
|
|
2
|
+
// `dir` is the session directory (.z0g/sessions/<id>) so the plan is per-chat.
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const FILE = (dir) => path.join(dir, "plan.json");
|
|
7
|
+
|
|
8
|
+
export async function savePlan(dir, plan) {
|
|
9
|
+
const f = FILE(dir);
|
|
10
|
+
await fs.mkdir(path.dirname(f), { recursive: true });
|
|
11
|
+
await fs.writeFile(f, JSON.stringify({ tool: "z0gcode", ts: new Date().toISOString(), plan }, null, 2), "utf8");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function loadPlan(dir) {
|
|
15
|
+
try {
|
|
16
|
+
const d = JSON.parse(await fs.readFile(FILE(dir), "utf8"));
|
|
17
|
+
return Array.isArray(d.plan) ? d.plan : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/prompt.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Dependency-free interactive prompts (raw ANSI). Currently: a single-select
|
|
2
|
+
// list navigable with the arrow keys, decoupled from its look via renderFrame.
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
|
|
5
|
+
// arrowSelect: navigate a list with the arrow keys, Enter to choose, Esc/Ctrl-C
|
|
6
|
+
// to cancel. Resolves to the chosen item, or undefined if cancelled, or
|
|
7
|
+
// { __action, item } when an action key fires. Resolves undefined immediately
|
|
8
|
+
// when stdin/stdout is not a TTY so the caller can fall back (e.g. a number
|
|
9
|
+
// prompt).
|
|
10
|
+
//
|
|
11
|
+
// Options:
|
|
12
|
+
// - renderFrame(items, index, ctx) returns the ENTIRE multi-line frame; each
|
|
13
|
+
// logical line must fit the terminal width (caller truncates), or wrapping
|
|
14
|
+
// breaks the in-place redraw. ctx = { filter, filtering }.
|
|
15
|
+
// - filterable: enable type-to-filter. While on, letters/digits build a filter
|
|
16
|
+
// (only the arrow keys navigate; j/k/q/1-9 become filter input); Esc clears
|
|
17
|
+
// the filter, or cancels when it is already empty.
|
|
18
|
+
// - filterText(item): the text a filter matches against; return null to always
|
|
19
|
+
// keep the item visible (e.g. a "New chat" entry).
|
|
20
|
+
// - onActionKey(key, str): return an action name for a key combo (e.g. ctrl-r);
|
|
21
|
+
// the promise resolves { __action: name, item: highlighted }.
|
|
22
|
+
export function arrowSelect({ items, initialIndex = 0, renderFrame, clearOnExit = false, filterable = false, filterText, onActionKey }) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const stdin = process.stdin;
|
|
25
|
+
const stdout = process.stdout;
|
|
26
|
+
if (!stdin.isTTY || !stdout.isTTY || !items || items.length === 0) {
|
|
27
|
+
resolve(undefined);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let index = Math.min(Math.max(initialIndex | 0, 0), items.length - 1);
|
|
32
|
+
let filter = "";
|
|
33
|
+
let prevLines = 0;
|
|
34
|
+
|
|
35
|
+
const matches = (it) => {
|
|
36
|
+
if (!filterable || !filter) return true;
|
|
37
|
+
const t = filterText ? filterText(it) : String(it);
|
|
38
|
+
return t == null ? true : String(t).toLowerCase().includes(filter.toLowerCase());
|
|
39
|
+
};
|
|
40
|
+
const visible = () => (filterable ? items.filter(matches) : items);
|
|
41
|
+
|
|
42
|
+
readline.emitKeypressEvents(stdin);
|
|
43
|
+
const saved = stdin.listeners("keypress").slice();
|
|
44
|
+
for (const l of saved) stdin.removeListener("keypress", l);
|
|
45
|
+
|
|
46
|
+
const wasRaw = !!stdin.isRaw;
|
|
47
|
+
if (stdin.setRawMode) stdin.setRawMode(true);
|
|
48
|
+
stdin.resume();
|
|
49
|
+
stdout.write("\x1b[?25l"); // hide cursor
|
|
50
|
+
|
|
51
|
+
const clear = () => {
|
|
52
|
+
if (prevLines > 0) {
|
|
53
|
+
readline.moveCursor(stdout, 0, -prevLines);
|
|
54
|
+
readline.cursorTo(stdout, 0);
|
|
55
|
+
readline.clearScreenDown(stdout);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const draw = () => {
|
|
59
|
+
clear();
|
|
60
|
+
const vis = visible();
|
|
61
|
+
if (index >= vis.length) index = Math.max(0, vis.length - 1);
|
|
62
|
+
const frame = renderFrame(vis, index, { filter, filtering: filterable });
|
|
63
|
+
const body = frame.endsWith("\n") ? frame : frame + "\n";
|
|
64
|
+
stdout.write(body);
|
|
65
|
+
prevLines = (body.match(/\n/g) || []).length;
|
|
66
|
+
};
|
|
67
|
+
const cleanup = () => {
|
|
68
|
+
stdin.removeListener("keypress", onKey);
|
|
69
|
+
if (clearOnExit && prevLines > 0) {
|
|
70
|
+
readline.moveCursor(stdout, 0, -prevLines);
|
|
71
|
+
readline.cursorTo(stdout, 0);
|
|
72
|
+
readline.clearScreenDown(stdout);
|
|
73
|
+
}
|
|
74
|
+
stdout.write("\x1b[?25h"); // show cursor
|
|
75
|
+
try {
|
|
76
|
+
if (stdin.setRawMode) stdin.setRawMode(wasRaw);
|
|
77
|
+
} catch {}
|
|
78
|
+
for (const l of saved) stdin.on("keypress", l); // restore outer listeners
|
|
79
|
+
};
|
|
80
|
+
const finish = (val) => {
|
|
81
|
+
cleanup();
|
|
82
|
+
resolve(val);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const onKey = (str, key) => {
|
|
86
|
+
try {
|
|
87
|
+
key = key || {};
|
|
88
|
+
const vis = visible();
|
|
89
|
+
const len = vis.length;
|
|
90
|
+
if (onActionKey) {
|
|
91
|
+
const a = onActionKey(key, str);
|
|
92
|
+
if (a) {
|
|
93
|
+
finish({ __action: a, item: len ? vis[index] : null });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (key.name === "up" || (!filterable && key.name === "k")) {
|
|
98
|
+
if (len) index = (index - 1 + len) % len;
|
|
99
|
+
draw();
|
|
100
|
+
} else if (key.name === "down" || (!filterable && key.name === "j")) {
|
|
101
|
+
if (len) index = (index + 1) % len;
|
|
102
|
+
draw();
|
|
103
|
+
} else if (key.name === "return" || key.name === "enter") {
|
|
104
|
+
if (len) finish(vis[index]);
|
|
105
|
+
} else if ((key.ctrl && key.name === "c") || (key.ctrl && key.name === "d")) {
|
|
106
|
+
finish(undefined);
|
|
107
|
+
} else if (key.name === "escape") {
|
|
108
|
+
if (filterable && filter) {
|
|
109
|
+
filter = "";
|
|
110
|
+
index = 0;
|
|
111
|
+
draw();
|
|
112
|
+
} else finish(undefined);
|
|
113
|
+
} else if (!filterable && key.name === "q") {
|
|
114
|
+
finish(undefined);
|
|
115
|
+
} else if (key.name === "home" || (key.ctrl && key.name === "a")) {
|
|
116
|
+
index = 0;
|
|
117
|
+
draw();
|
|
118
|
+
} else if (key.name === "end" || (key.ctrl && key.name === "e")) {
|
|
119
|
+
index = Math.max(0, len - 1);
|
|
120
|
+
draw();
|
|
121
|
+
} else if (filterable && key.name === "backspace") {
|
|
122
|
+
filter = filter.slice(0, -1);
|
|
123
|
+
index = 0;
|
|
124
|
+
draw();
|
|
125
|
+
} else if (filterable && str && str.length === 1 && str >= " " && !key.ctrl) {
|
|
126
|
+
filter += str;
|
|
127
|
+
index = 0;
|
|
128
|
+
draw();
|
|
129
|
+
} else if (!filterable && str && /^[1-9]$/.test(str)) {
|
|
130
|
+
const n = Number(str) - 1;
|
|
131
|
+
if (n < items.length) {
|
|
132
|
+
index = n;
|
|
133
|
+
draw();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
finish(undefined); // never leave the terminal in raw mode on a render error
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
draw();
|
|
143
|
+
} catch {
|
|
144
|
+
finish(undefined);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
stdin.on("keypress", onKey);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Provenance manifest: binds every file change to the 0G model that produced it.
|
|
2
|
+
// This is the verifiable-provenance differentiator: a closed-provider CLI cannot
|
|
3
|
+
// prove which model wrote which code. We capture the model id and response id
|
|
4
|
+
// reported by 0G plus the before/after hashes. Full TEE-quote verification is roadmap.
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const sha256 = (s) => crypto.createHash("sha256").update(s ?? "", "utf8").digest("hex");
|
|
10
|
+
|
|
11
|
+
// `dir` is the session directory (.z0g/sessions/<id>) so provenance is per-chat.
|
|
12
|
+
const MANIFEST = (dir) => path.join(dir, "provenance.json");
|
|
13
|
+
|
|
14
|
+
export function makeProvenance(dir) {
|
|
15
|
+
const file = MANIFEST(dir);
|
|
16
|
+
const entries = [];
|
|
17
|
+
return {
|
|
18
|
+
async record({ pathRel, before, after, model, responseId, trace }) {
|
|
19
|
+
// 0G returns an x_0g_trace with the on-chain provider node that served the
|
|
20
|
+
// request and a 0G request id: honest, verifiable TEE evidence.
|
|
21
|
+
const tee = trace ? { provider: trace.provider || null, request_id: trace.request_id || null } : null;
|
|
22
|
+
entries.push({
|
|
23
|
+
path: pathRel,
|
|
24
|
+
sha256_before: sha256(before),
|
|
25
|
+
sha256_after: sha256(after),
|
|
26
|
+
model: model || "unknown",
|
|
27
|
+
response_id: responseId || null,
|
|
28
|
+
tee_trace: tee,
|
|
29
|
+
ts: new Date().toISOString(),
|
|
30
|
+
});
|
|
31
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
32
|
+
await fs.writeFile(file, JSON.stringify({ tool: "z0gcode", provider: "0g-compute", entries }, null, 2), "utf8");
|
|
33
|
+
},
|
|
34
|
+
count() {
|
|
35
|
+
return entries.length;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function loadProvenance(dir) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(await fs.readFile(MANIFEST(dir), "utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/sessions.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Multi-chat sessions per project. Each session lives in its own directory
|
|
2
|
+
// under .z0g/sessions/<id>/ and isolates its conversation (session.json),
|
|
3
|
+
// plan (plan.json), and provenance (provenance.json). File changes on disk are
|
|
4
|
+
// shared across sessions; the recorded history/plan/provenance are not.
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const root = (cwd) => path.join(cwd, ".z0g", "sessions");
|
|
10
|
+
export const sessionDir = (cwd, id) => path.join(root(cwd), id);
|
|
11
|
+
const sessionFile = (cwd, id) => path.join(sessionDir(cwd, id), "session.json");
|
|
12
|
+
|
|
13
|
+
function genId() {
|
|
14
|
+
// Sortable: base36 timestamp + a short random suffix (unique per run).
|
|
15
|
+
const t = Date.now().toString(36);
|
|
16
|
+
const r = Math.floor(Math.random() * 1e7).toString(36);
|
|
17
|
+
return `${t}-${r}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Auto-title from the first user message (single line, truncated).
|
|
21
|
+
export function autoTitle(messages) {
|
|
22
|
+
const first = (messages || []).find(
|
|
23
|
+
(m) => m.role === "user" && typeof m.content === "string" && m.content.trim()
|
|
24
|
+
);
|
|
25
|
+
if (!first) return "New chat";
|
|
26
|
+
const t = first.content.trim().replace(/\s+/g, " ");
|
|
27
|
+
return t.length > 48 ? t.slice(0, 47) + "…" : t;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function countTurns(messages) {
|
|
31
|
+
return Array.isArray(messages) ? messages.filter((m) => m.role === "user" || m.role === "assistant").length : 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readSession(cwd, id) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(await fs.readFile(sessionFile(cwd, id), "utf8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createSession(cwd, { title = "" } = {}) {
|
|
43
|
+
const id = genId();
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
const data = { tool: "z0gcode", id, title, created: now, updated: now, messages: [] };
|
|
46
|
+
await fs.mkdir(sessionDir(cwd, id), { recursive: true });
|
|
47
|
+
await fs.writeFile(sessionFile(cwd, id), JSON.stringify(data, null, 2), "utf8");
|
|
48
|
+
return { id, dir: sessionDir(cwd, id), title, history: [] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readMessages(cwd, id) {
|
|
52
|
+
const d = await readSession(cwd, id);
|
|
53
|
+
return d && Array.isArray(d.messages) && d.messages.length ? d.messages : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function saveMessages(cwd, id, messages) {
|
|
57
|
+
const d = (await readSession(cwd, id)) || { tool: "z0gcode", id, title: "", created: new Date().toISOString() };
|
|
58
|
+
d.messages = messages;
|
|
59
|
+
d.updated = new Date().toISOString();
|
|
60
|
+
if (!d.title) d.title = autoTitle(messages);
|
|
61
|
+
await fs.mkdir(sessionDir(cwd, id), { recursive: true });
|
|
62
|
+
await fs.writeFile(sessionFile(cwd, id), JSON.stringify(d, null, 2), "utf8");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Sync listing (used before async flows and by the picker). Newest first.
|
|
66
|
+
export function listSessions(cwd) {
|
|
67
|
+
const dir = root(cwd);
|
|
68
|
+
if (!existsSync(dir)) return [];
|
|
69
|
+
const out = [];
|
|
70
|
+
let ids = [];
|
|
71
|
+
try {
|
|
72
|
+
ids = readdirSync(dir);
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
for (const id of ids) {
|
|
77
|
+
const f = sessionFile(cwd, id);
|
|
78
|
+
if (!existsSync(f)) continue;
|
|
79
|
+
try {
|
|
80
|
+
const d = JSON.parse(readFileSync(f, "utf8"));
|
|
81
|
+
out.push({
|
|
82
|
+
id,
|
|
83
|
+
title: (d.title && d.title.trim()) || autoTitle(d.messages),
|
|
84
|
+
updated: d.updated || d.created || "",
|
|
85
|
+
messageCount: countTurns(d.messages),
|
|
86
|
+
});
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
out.sort((a, b) => String(b.updated).localeCompare(String(a.updated)));
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function hasSessions(cwd) {
|
|
94
|
+
return listSessions(cwd).length > 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function mostRecent(cwd) {
|
|
98
|
+
const s = listSessions(cwd);
|
|
99
|
+
return s.length ? s[0].id : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function renameSession(cwd, id, title) {
|
|
103
|
+
const d = await readSession(cwd, id);
|
|
104
|
+
if (!d) return false;
|
|
105
|
+
const t = String(title || "").trim();
|
|
106
|
+
if (!t) return false;
|
|
107
|
+
d.title = t;
|
|
108
|
+
await fs.writeFile(sessionFile(cwd, id), JSON.stringify(d, null, 2), "utf8");
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function deleteSession(cwd, id) {
|
|
113
|
+
await fs.rm(sessionDir(cwd, id), { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Synchronous prune of a session that persisted no messages. Safe to call from
|
|
117
|
+
// a SIGINT handler, where async cleanup would not run before the process exits.
|
|
118
|
+
export function pruneEmptySync(cwd, id) {
|
|
119
|
+
try {
|
|
120
|
+
const s = listSessions(cwd).find((x) => x.id === id);
|
|
121
|
+
if (s && s.messageCount === 0) rmSync(sessionDir(cwd, id), { recursive: true, force: true });
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Import a legacy single .z0g/session.json (+ plan/provenance) as one session,
|
|
126
|
+
// exactly once, so upgrading loses no history.
|
|
127
|
+
export async function migrateLegacy(cwd) {
|
|
128
|
+
const legacy = path.join(cwd, ".z0g", "session.json");
|
|
129
|
+
if (!existsSync(legacy) || hasSessions(cwd)) return;
|
|
130
|
+
let messages = [];
|
|
131
|
+
try {
|
|
132
|
+
const d = JSON.parse(readFileSync(legacy, "utf8"));
|
|
133
|
+
messages = Array.isArray(d.messages) ? d.messages : [];
|
|
134
|
+
} catch {
|
|
135
|
+
messages = [];
|
|
136
|
+
}
|
|
137
|
+
if (messages.length) {
|
|
138
|
+
const { id } = await createSession(cwd, { title: autoTitle(messages) });
|
|
139
|
+
await saveMessages(cwd, id, messages);
|
|
140
|
+
for (const name of ["plan.json", "provenance.json"]) {
|
|
141
|
+
const src = path.join(cwd, ".z0g", name);
|
|
142
|
+
if (existsSync(src)) {
|
|
143
|
+
try {
|
|
144
|
+
await fs.copyFile(src, path.join(sessionDir(cwd, id), name));
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await fs.rename(legacy, legacy + ".migrated");
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Add .z0g/ to the project .gitignore (only in a git repo, only once) so
|
|
155
|
+
// sessions and provenance are not committed by accident.
|
|
156
|
+
export async function ensureGitignore(cwd) {
|
|
157
|
+
if (!existsSync(path.join(cwd, ".git"))) return;
|
|
158
|
+
const gi = path.join(cwd, ".gitignore");
|
|
159
|
+
let content = "";
|
|
160
|
+
try {
|
|
161
|
+
content = readFileSync(gi, "utf8");
|
|
162
|
+
} catch {}
|
|
163
|
+
if (/^\s*\.z0g\/?\s*$/m.test(content)) return; // already ignored
|
|
164
|
+
const prefix = content && !content.endsWith("\n") ? "\n" : "";
|
|
165
|
+
try {
|
|
166
|
+
await fs.appendFile(gi, prefix + ".z0g/\n");
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// User settings, persisted like Claude Code's settings.json.
|
|
2
|
+
// Global: ~/.z0gcode/settings.json. Project overrides: <cwd>/.z0g/settings.json.
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const GLOBAL_DIR = path.join(os.homedir(), ".z0gcode");
|
|
8
|
+
const GLOBAL_FILE = path.join(GLOBAL_DIR, "settings.json");
|
|
9
|
+
const projectFile = (cwd) => path.join(cwd, ".z0g", "settings.json");
|
|
10
|
+
|
|
11
|
+
function readJson(file) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
14
|
+
} catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Merge global then project (project wins).
|
|
20
|
+
export function loadSettings(cwd) {
|
|
21
|
+
const g = readJson(GLOBAL_FILE);
|
|
22
|
+
const p = cwd ? readJson(projectFile(cwd)) : {};
|
|
23
|
+
return { ...g, ...p };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Save a key to the global settings file.
|
|
27
|
+
export function saveSetting(key, value) {
|
|
28
|
+
const s = readJson(GLOBAL_FILE);
|
|
29
|
+
s[key] = value;
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(GLOBAL_DIR, { recursive: true });
|
|
32
|
+
writeFileSync(GLOBAL_FILE, JSON.stringify(s, null, 2) + "\n");
|
|
33
|
+
} catch {}
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const settingsPath = GLOBAL_FILE;
|