@krishivpb60/aether-ai-cli 1.3.3 → 1.3.4

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/HIGHLIGHTS.md CHANGED
@@ -1,3 +1,9 @@
1
+ # Aether CLI v1.3.4 Highlights
2
+ - **AI-Powered Workspace Search & Code Indexer (`/search`)**:
3
+ - Adds `/search <query>` slash command to scan all workspace text files for keyword matches, showing line numbers and code snippets.
4
+ - Supports `/search --ai <query>` to run a semantic search using the active AI reasoning model.
5
+ - Automatically ignores binaries, files exceeding 250KB, and build/dependency/git directories.
6
+
1
7
  # Aether CLI v1.3.3 Highlights
2
8
  - **Codex & Claude Code Slash Commands**: Added 7 new advanced developer experience (DX) commands:
3
9
  - `/review`: Analyze staged/unstaged git changes and stream an AI-powered code review.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krishivpb60/aether-ai-cli",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "Aether Core AI — A cyberpunk command-line AI assistant with multi-mode reasoning, 12-node failover mesh, file context injection, and offline fallbacks.",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
package/src/ai/router.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  callCohere,
13
13
  } from "./universal.js";
14
14
  import { estimateTokens, recordTokenUsage } from "./tokens.js";
15
+ import { recordLatency } from "./telemetry.js";
15
16
 
16
17
  /**
17
18
  * Routes a prompt through the universal AI failover mesh.
@@ -31,11 +32,14 @@ export async function routePrompt(prompt, systemPrompt, config, onToken, history
31
32
  // ── Node 0: Local Math Solver ───────────────────────────
32
33
  const mathExpr = detectMathExpression(prompt);
33
34
  if (mathExpr) {
35
+ const startTime = performance.now();
34
36
  const mathResult = solveMath(mathExpr);
35
37
  if (mathResult) {
38
+ const latencyMs = performance.now() - startTime;
36
39
  const pTokens = estimateTokens(systemPrompt + prompt);
37
40
  const cTokens = estimateTokens(mathResult.text);
38
41
  const usage = recordTokenUsage("local-math", pTokens, cTokens);
42
+ recordLatency("local", "math-solver", latencyMs, pTokens, cTokens, true);
39
43
  return { ...mathResult, provider: "local", node: 0, usage };
40
44
  }
41
45
  }
@@ -57,10 +61,13 @@ export async function routePrompt(prompt, systemPrompt, config, onToken, history
57
61
 
58
62
  // ── No providers configured → Krylo ────────────────────
59
63
  if (active.length === 0) {
64
+ const startTime = performance.now();
60
65
  const kryloReply = generateKryloReply(prompt);
66
+ const latencyMs = performance.now() - startTime;
61
67
  const pTokens = estimateTokens(systemPrompt + prompt);
62
68
  const cTokens = estimateTokens(kryloReply.text);
63
69
  const usage = recordTokenUsage("krylo-local", pTokens, cTokens);
70
+ recordLatency("krylo-fallback", "local", latencyMs, pTokens, cTokens, true);
64
71
  return { ...kryloReply, provider: "krylo-fallback", node: 0, usage };
65
72
  }
66
73
 
@@ -69,6 +76,7 @@ export async function routePrompt(prompt, systemPrompt, config, onToken, history
69
76
  let nodeIndex = 1;
70
77
 
71
78
  for (const { id, provider, apiKey } of active) {
79
+ const startTime = performance.now();
72
80
  try {
73
81
  const model = config[`${id.toUpperCase()}_MODEL`] || provider.defaultModel;
74
82
  let result;
@@ -103,22 +111,31 @@ export async function routePrompt(prompt, systemPrompt, config, onToken, history
103
111
  );
104
112
  }
105
113
 
114
+ const latencyMs = performance.now() - startTime;
106
115
  const pTokens = estimateTokens(systemPrompt + prompt + history.map(h => h.content).join(""));
107
116
  const cTokens = estimateTokens(result.text);
108
117
  const usage = recordTokenUsage(result.model, pTokens, cTokens);
109
118
 
119
+ recordLatency(provider.name, result.model, latencyMs, pTokens, cTokens, true);
120
+
110
121
  return { ...result, node: nodeIndex, usage };
111
122
  } catch (err) {
123
+ const latencyMs = performance.now() - startTime;
124
+ const pTokens = estimateTokens(systemPrompt + prompt + history.map(h => h.content).join(""));
125
+ recordLatency(provider.name, "unknown", latencyMs, pTokens, 0, false);
112
126
  errors.push(`[Node ${nodeIndex} ${provider.name}] ${err.message}`);
113
127
  nodeIndex++;
114
128
  }
115
129
  }
116
130
 
117
131
  // ── Final Fallback: Krylo Companion ─────────────────────
132
+ const startTimeKrylo = performance.now();
118
133
  const kryloReply = generateKryloReply(prompt);
134
+ const latencyMsKrylo = performance.now() - startTimeKrylo;
119
135
  const pTokens = estimateTokens(systemPrompt + prompt + history.map(h => h.content).join(""));
120
136
  const cTokens = estimateTokens(kryloReply.text);
121
137
  const usage = recordTokenUsage("krylo-local", pTokens, cTokens);
138
+ recordLatency("krylo-fallback", "local", latencyMsKrylo, pTokens, cTokens, true);
122
139
  return {
123
140
  ...kryloReply,
124
141
  provider: "krylo-fallback",
@@ -0,0 +1,94 @@
1
+ import { readdirSync, statSync, readFileSync } from "node:fs";
2
+ import { join, sep, relative } from "node:path";
3
+
4
+ const IGNORE_DIRS = new Set([
5
+ ".git",
6
+ "node_modules",
7
+ "build",
8
+ "dist",
9
+ ".agents",
10
+ "aether_ai_agent_cli.egg-info",
11
+ "aether_ai_cli.egg-info",
12
+ "temp-test-home",
13
+ "temp-test-home-updater",
14
+ "temp-test-home-search"
15
+ ]);
16
+
17
+ const BINARY_EXTS = new Set([
18
+ ".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp",
19
+ ".zip", ".tar", ".gz", ".rar", ".7z",
20
+ ".pdf", ".exe", ".dll", ".so", ".dylib", ".bin",
21
+ ".mp3", ".mp4", ".wav", ".avi", ".mov",
22
+ ".woff", ".woff2", ".ttf", ".eot"
23
+ ]);
24
+
25
+ /**
26
+ * Recursively walks a directory and yields all text files.
27
+ * @param {string} dir - Directory to scan
28
+ * @param {string} rootDir - Root directory for ignore matching
29
+ * @returns {string[]} List of absolute paths
30
+ */
31
+ export function crawlDirectory(dir, rootDir = dir) {
32
+ let files = [];
33
+ let entries = [];
34
+ try {
35
+ entries = readdirSync(dir);
36
+ } catch {
37
+ return files;
38
+ }
39
+
40
+ for (const entry of entries) {
41
+ if (IGNORE_DIRS.has(entry)) continue;
42
+ const fullPath = join(dir, entry);
43
+ try {
44
+ const stat = statSync(fullPath);
45
+ if (stat.isDirectory()) {
46
+ files = files.concat(crawlDirectory(fullPath, rootDir));
47
+ } else if (stat.isFile()) {
48
+ if (stat.size > 250 * 1024) continue; // Skip files larger than 250KB
49
+
50
+ const dotIdx = entry.lastIndexOf(".");
51
+ const ext = dotIdx !== -1 ? entry.slice(dotIdx).toLowerCase() : "";
52
+ if (BINARY_EXTS.has(ext)) continue;
53
+
54
+ files.push(fullPath);
55
+ }
56
+ } catch {
57
+ // Ignore stat failures
58
+ }
59
+ }
60
+ return files;
61
+ }
62
+
63
+ /**
64
+ * Searches all text files in the workspace for occurrences of query.
65
+ * @param {string} query - Query string
66
+ * @param {string} rootDir - The workspace root path
67
+ * @returns {Array<{ filePath: string, relativePath: string, lineNumber: number, lineContent: string }>}
68
+ */
69
+ export function workspaceSearch(query, rootDir = process.cwd()) {
70
+ const files = crawlDirectory(rootDir, rootDir);
71
+ const results = [];
72
+ const lowerQuery = query.toLowerCase().trim();
73
+
74
+ for (const file of files) {
75
+ try {
76
+ const content = readFileSync(file, "utf8");
77
+ const lines = content.split(/\r?\n/);
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const line = lines[i];
80
+ if (line.toLowerCase().includes(lowerQuery)) {
81
+ results.push({
82
+ filePath: file,
83
+ relativePath: relative(rootDir, file),
84
+ lineNumber: i + 1,
85
+ lineContent: line.trim()
86
+ });
87
+ }
88
+ }
89
+ } catch {
90
+ // Ignore read failures
91
+ }
92
+ }
93
+ return results;
94
+ }
@@ -0,0 +1,72 @@
1
+ // ═══════════════════════════════════════════════════════════
2
+ // AETHER AI CLI — Telemetry & Latency Logger
3
+ // Logs request latencies, prompt speeds, and meshes provider logs.
4
+ // ═══════════════════════════════════════════════════════════
5
+
6
+ import { getSessionTokenStats, getBreakdownByModel } from "./tokens.js";
7
+ import { getActiveProviders, PROVIDERS } from "./providers.js";
8
+ import { listSessions } from "../config.js";
9
+
10
+ const latencyLogs = [];
11
+
12
+ /**
13
+ * Records telemetry metrics for an AI call.
14
+ * @param {string} provider - The provider name
15
+ * @param {string} model - The model name
16
+ * @param {number} latencyMs - The request latency in ms
17
+ * @param {number} promptTokens - Prompt token count
18
+ * @param {number} completionTokens - Completion token count
19
+ * @param {boolean} success - Whether the call succeeded
20
+ */
21
+ export function recordLatency(provider, model, latencyMs, promptTokens, completionTokens, success) {
22
+ latencyLogs.push({
23
+ timestamp: new Date().toISOString(),
24
+ provider: provider || "unknown",
25
+ model: model || "unknown",
26
+ latencyMs: Math.round(latencyMs),
27
+ promptTokens: promptTokens || 0,
28
+ completionTokens: completionTokens || 0,
29
+ success: !!success,
30
+ });
31
+
32
+ // Limit to last 50 entries
33
+ if (latencyLogs.length > 50) {
34
+ latencyLogs.shift();
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Returns raw latency records.
40
+ * @returns {Array}
41
+ */
42
+ export function getLatencyLogs() {
43
+ return latencyLogs;
44
+ }
45
+
46
+ /**
47
+ * Gathers all metrics needed for the web dashboard visualization.
48
+ * @param {object} config - Aether configuration object
49
+ * @returns {object}
50
+ */
51
+ export function getTelemetryData(config = {}) {
52
+ // Map active status for all providers in our registry
53
+ const mesh = Object.entries(PROVIDERS).map(([id, provider]) => {
54
+ const isConfigured = !!config[provider.key];
55
+ return {
56
+ id,
57
+ name: provider.name,
58
+ configured: isConfigured,
59
+ defaultModel: provider.defaultModel,
60
+ tier: provider.tier,
61
+ description: provider.description,
62
+ };
63
+ });
64
+
65
+ return {
66
+ tokenStats: getSessionTokenStats(),
67
+ modelBreakdown: getBreakdownByModel(),
68
+ latencyLogs: [...latencyLogs],
69
+ meshStructure: mesh,
70
+ sessions: listSessions(),
71
+ };
72
+ }
package/src/chat.js CHANGED
@@ -138,7 +138,8 @@ export async function startChat(options = {}) {
138
138
  "/providers", "/export", "/status", "/copy", "/exit", "/quit",
139
139
  "/theme", "/themes", "/history-clear", "/game", "/abort", "/cmd", "/write",
140
140
  "/commit", "/run", "/history", "/autopilot", "/tokens", "/update",
141
- "/review", "/diagnose", "/explain", "/refactor", "/bug", "/doc", "/translate"
141
+ "/review", "/diagnose", "/explain", "/refactor", "/bug", "/doc", "/translate",
142
+ "/search"
142
143
  ];
143
144
  const customCmds = aiConfig.CUSTOM_COMMANDS || {};
144
145
  const commands = [...builtIn, ...Object.keys(customCmds)];
@@ -427,7 +428,7 @@ export async function startChat(options = {}) {
427
428
  "/theme", "/themes", "/history-clear", "/game", "/abort", "/cmd",
428
429
  "/guess", "/write", "/commit", "/run", "/history", "/autopilot", "/tokens",
429
430
  "/update", "/review", "/diagnose", "/explain", "/refactor", "/bug", "/doc",
430
- "/translate"
431
+ "/translate", "/search"
431
432
  ];
432
433
 
433
434
  const customCmds = aiConfig.CUSTOM_COMMANDS || {};
@@ -540,6 +541,10 @@ async function handleCommand(input, ctx) {
540
541
  await handleFileAICommand(cmd, args, ctx);
541
542
  break;
542
543
 
544
+ case "/search":
545
+ await handleSearchCommand(args, ctx);
546
+ break;
547
+
543
548
  case "/theme":
544
549
  await handleThemeSwitch(args);
545
550
  break;
@@ -646,6 +651,7 @@ function showHelp(aiConfig) {
646
651
  console.log(keyValue("/bug <file>", "AI-audit a file to find potential logic bugs"));
647
652
  console.log(keyValue("/doc <file>", "AI-generate documentation/docstrings for a file"));
648
653
  console.log(keyValue("/translate <file> <lang>", "AI-translate code of a file to another language"));
654
+ console.log(keyValue("/search <query>", "Find matches in code files (use --ai for semantic search)"));
649
655
  console.log(keyValue("/exit", "End session"));
650
656
 
651
657
  if (aiConfig && aiConfig.CUSTOM_COMMANDS) {
@@ -1615,3 +1621,72 @@ async function handleFileAICommand(cmdName, args, ctx) {
1615
1621
  console.log("\n" + label.system + " " + colors.danger(`Error: ${err.message}\n`));
1616
1622
  }
1617
1623
  }
1624
+
1625
+ /**
1626
+ * Handler for the /search command (workspace file crawler and AI semantic finder).
1627
+ */
1628
+ async function handleSearchCommand(args, ctx) {
1629
+ const isAi = args[0] === "--ai";
1630
+ const queryArgs = isAi ? args.slice(1) : args;
1631
+ const query = queryArgs.join(" ").trim();
1632
+
1633
+ if (!query) {
1634
+ console.log("\n" + label.system + " " + colors.warning("Usage: /search [--ai] <query_string>\n"));
1635
+ return;
1636
+ }
1637
+
1638
+ const { workspaceSearch, crawlDirectory } = await import("./search.js");
1639
+
1640
+ if (isAi) {
1641
+ console.log("\n" + label.system + " " + colors.muted("Scanning workspace project tree for semantic search..."));
1642
+ const files = crawlDirectory(process.cwd());
1643
+ const { relative } = await import("node:path");
1644
+ const relativePaths = files.map((f) => relative(process.cwd(), f).replace(/\\/g, "/"));
1645
+
1646
+ // Construct semantic prompt
1647
+ const prompt = `Here is the directory structure / file listing of the current workspace:\n\n${relativePaths.slice(0, 100).join("\n")}\n\nBased on this file listing, identify and explain where the following logic or system is implemented, listing the relevant files: ${query}`;
1648
+
1649
+ await executeAISpecialCommand(prompt, `Semantic search: "${query}"`, ctx);
1650
+ return;
1651
+ }
1652
+
1653
+ console.log("\n" + label.system + " " + colors.muted(`Searching workspace for "${query}"...`));
1654
+ const results = workspaceSearch(query);
1655
+
1656
+ if (results.length === 0) {
1657
+ console.log("\n" + label.system + " " + colors.warning(`✓ No matches found for "${query}" in workspace.\n`));
1658
+ return;
1659
+ }
1660
+
1661
+ console.log("\n" + separator("━"));
1662
+ console.log(colors.accent.bold(` ★ WORKSPACE SEARCH RESULTS FOR "${query.toUpperCase()}" ★`));
1663
+ console.log(separator("─"));
1664
+
1665
+ // Print header
1666
+ console.log(
1667
+ colors.brand(" " + "File Path".padEnd(45) + "Line".padStart(6) + " " + "Preview")
1668
+ );
1669
+ console.log(colors.dim(" " + "─".repeat(80)));
1670
+
1671
+ // Display matches (limit to top 50 to prevent terminal overflow)
1672
+ const displayLimit = 50;
1673
+ const visibleResults = results.slice(0, displayLimit);
1674
+
1675
+ for (const match of visibleResults) {
1676
+ const truncatedPath = match.relativePath.length > 43 ? "..." + match.relativePath.slice(-40) : match.relativePath;
1677
+ const truncatedLine = match.lineContent.length > 50 ? match.lineContent.slice(0, 47) + "..." : match.lineContent;
1678
+ console.log(
1679
+ " " + colors.text(truncatedPath.padEnd(45)) +
1680
+ colors.brand(match.lineNumber.toString().padStart(6)) +
1681
+ " " + colors.muted(truncatedLine)
1682
+ );
1683
+ }
1684
+
1685
+ console.log(separator("─"));
1686
+ if (results.length > displayLimit) {
1687
+ console.log(" " + colors.warning(`⚠ Showing first ${displayLimit} of ${results.length} total matches.`));
1688
+ } else {
1689
+ console.log(" " + colors.success(`✓ Found ${results.length} matches across the workspace.`));
1690
+ }
1691
+ console.log(separator("━") + "\n");
1692
+ }
@@ -0,0 +1,52 @@
1
+ import { test, before, after } from "node:test";
2
+ import assert from "node:assert";
3
+ import { mkdir, writeFile, rm } from "node:fs/promises";
4
+ import { join, relative } from "node:path";
5
+ import { workspaceSearch, crawlDirectory } from "../src/ai/search.js";
6
+
7
+ const testDir = join(process.cwd(), "temp-test-home-search");
8
+
9
+ test("Workspace Search Engine Suite", async (t) => {
10
+ before(async () => {
11
+ await mkdir(testDir, { recursive: true });
12
+ // Write test files
13
+ await writeFile(join(testDir, "file1.txt"), "hello world\nthis is a search test\nbye");
14
+ await writeFile(join(testDir, "file2.js"), "function test() {\n console.log('hello search');\n}");
15
+ await writeFile(join(testDir, "file3.png"), "binary data matches nothing");
16
+
17
+ const subDir = join(testDir, "subdir");
18
+ await mkdir(subDir, { recursive: true });
19
+ await writeFile(join(subDir, "file4.txt"), "nested matches here under search term");
20
+ });
21
+
22
+ after(async () => {
23
+ await rm(testDir, { recursive: true, force: true });
24
+ });
25
+
26
+ await t.test("crawlDirectory finds all text files and excludes binaries/ignored folders", () => {
27
+ const files = crawlDirectory(testDir, testDir);
28
+ const relativePaths = files.map((f) => relative(testDir, f).replace(/\\/g, "/"));
29
+
30
+ assert.ok(relativePaths.includes("file1.txt"));
31
+ assert.ok(relativePaths.includes("file2.js"));
32
+ assert.ok(relativePaths.includes("subdir/file4.txt"));
33
+ // Should exclude png
34
+ assert.strictEqual(relativePaths.includes("file3.png"), false);
35
+ });
36
+
37
+ await t.test("workspaceSearch returns matching files, lines, and content", () => {
38
+ const results = workspaceSearch("search", testDir);
39
+ // Should match in file1.txt, file2.js, and subdir/file4.txt
40
+ assert.strictEqual(results.length, 3);
41
+
42
+ const file1Match = results.find((r) => r.relativePath.replace(/\\/g, "/") === "file1.txt");
43
+ assert.ok(file1Match);
44
+ assert.strictEqual(file1Match.lineNumber, 2);
45
+ assert.strictEqual(file1Match.lineContent, "this is a search test");
46
+
47
+ const jsMatch = results.find((r) => r.relativePath.replace(/\\/g, "/") === "file2.js");
48
+ assert.ok(jsMatch);
49
+ assert.strictEqual(jsMatch.lineNumber, 2);
50
+ assert.strictEqual(jsMatch.lineContent, "console.log('hello search');");
51
+ });
52
+ });