Click "Selection" to interact with screen elements, or type a command below.
+
Ctrl+Alt+Space Toggle chat
Ctrl+Shift+O Toggle overlay
diff --git a/src/renderer/chat/preload.js b/src/renderer/chat/preload.js
index 7fdf626a..27baa0df 100644
--- a/src/renderer/chat/preload.js
+++ b/src/renderer/chat/preload.js
@@ -15,6 +15,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
// ===== SCREEN CAPTURE =====
captureScreen: (options) => ipcRenderer.send('capture-screen', options),
captureRegion: (x, y, width, height) => ipcRenderer.send('capture-region', { x, y, width, height }),
+ captureActiveWindow: () => ipcRenderer.send('capture-active-window'),
+
+ startActiveWindowStream: (options) => ipcRenderer.invoke('start-active-window-stream', options),
+ stopActiveWindowStream: () => ipcRenderer.invoke('stop-active-window-stream'),
+ statusActiveWindowStream: () => ipcRenderer.invoke('status-active-window-stream'),
// ===== AI SERVICE CONTROL =====
setAIProvider: (provider) => ipcRenderer.send('set-ai-provider', provider),
@@ -42,6 +47,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
onScreenCaptured: (callback) => ipcRenderer.on('screen-captured', (event, data) => callback(data)),
onVisualContextUpdate: (callback) => ipcRenderer.on('visual-context-update', (event, data) => callback(data)),
onProviderChanged: (callback) => ipcRenderer.on('provider-changed', (event, data) => callback(data)),
+ onAIStatusChanged: (callback) => ipcRenderer.on('ai-status-changed', (event, data) => callback(data)),
onScreenAnalysis: (callback) => ipcRenderer.on('screen-analysis', (event, data) => callback(data)),
onAuthStatus: (callback) => ipcRenderer.on('auth-status', (event, data) => callback(data)),
onTokenUsage: (callback) => ipcRenderer.on('token-usage', (event, data) => callback(data)),
@@ -92,6 +98,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Verify using verifier agent
agentVerify: (params) => ipcRenderer.invoke('agent-verify', params),
+
+ // Produce music using producer agent
+ agentProduce: (params) => ipcRenderer.invoke('agent-produce', params),
// Get agent system status
agentStatus: () => ipcRenderer.invoke('agent-status'),
@@ -108,5 +117,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
},
// ===== STATE =====
- getState: () => ipcRenderer.invoke('get-state')
+ getState: () => ipcRenderer.invoke('get-state'),
+
+ // ===== DEBUG / SMOKE (guarded in main by LIKU_ENABLE_DEBUG_IPC) =====
+ debugToggleChat: () => ipcRenderer.invoke('debug-toggle-chat'),
+ debugWindowState: () => ipcRenderer.invoke('debug-window-state')
});
diff --git a/src/renderer/overlay/preload.js b/src/renderer/overlay/preload.js
index fc275977..1db12bc2 100644
--- a/src/renderer/overlay/preload.js
+++ b/src/renderer/overlay/preload.js
@@ -67,6 +67,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Get current state
getState: () => ipcRenderer.invoke('get-state'),
+ // Debug / smoke controls (guarded in main by LIKU_ENABLE_DEBUG_IPC)
+ debugToggleChat: () => ipcRenderer.invoke('debug-toggle-chat'),
+ debugWindowState: () => ipcRenderer.invoke('debug-window-state'),
+
// Grid math helpers (inlined above)
getGridConstants: () => gridConstants,
labelToScreenCoordinates: (label) => labelToScreenCoordinates(label),
diff --git a/src/shared/inspect-types.js b/src/shared/inspect-types.js
index 3b613761..0abc8f4c 100644
--- a/src/shared/inspect-types.js
+++ b/src/shared/inspect-types.js
@@ -3,6 +3,22 @@
* Shared type definitions for inspect regions, window context, and action traces
*/
+/**
+ * Visual Frame Data Contract
+ * Standardized schema for any captured visual context (full screen, ROI, window, element)
+ * @typedef {Object} VisualFrame
+ * @property {string} dataURL - Base64 data URL of the image
+ * @property {number} width - Image width in pixels
+ * @property {number} height - Image height in pixels
+ * @property {number} timestamp - Capture timestamp (ms)
+ * @property {number} [originX] - X offset of the captured region on screen (0 for full screen)
+ * @property {number} [originY] - Y offset of the captured region on screen (0 for full screen)
+ * @property {string} coordinateSpace - Always 'screen-physical' for UIA/input compatibility
+ * @property {string} scope - 'screen' | 'region' | 'window' | 'element'
+ * @property {string} [sourceId] - Display/window source identifier
+ * @property {string} [sourceName] - Human-readable source name
+ */
+
/**
* Inspect Region Data Contract
* Represents an actionable region on screen detected through various sources
@@ -15,6 +31,9 @@
* @property {number} confidence - Detection confidence 0-1
* @property {string} source - Detection source (accessibility, ocr, heuristic)
* @property {number} timestamp - When this region was detected
+ * @property {Object} [clickPoint] - Preferred click point {x, y} from UIA TryGetClickablePoint
+ * @property {number[]|null} [runtimeId] - UIA RuntimeId for stable session-scoped element identity
+ * @property {string} coordinateSpace - Coordinate space (default 'screen-physical')
*/
/**
@@ -42,6 +61,35 @@
* @property {string} outcome - Result (success, failed, pending)
*/
+/**
+ * Create a VisualFrame from capture data
+ * @param {Object} params - Capture parameters
+ * @returns {VisualFrame}
+ */
+function createVisualFrame(params) {
+ return {
+ dataURL: params.dataURL || '',
+ width: params.width || 0,
+ height: params.height || 0,
+ timestamp: params.timestamp || Date.now(),
+ originX: params.originX ?? params.x ?? 0,
+ originY: params.originY ?? params.y ?? 0,
+ coordinateSpace: 'screen-physical',
+ scope: params.scope || params.type || 'screen',
+ sourceId: params.sourceId || null,
+ sourceName: params.sourceName || null,
+ windowHandle: Number.isFinite(Number(params.windowHandle)) ? Number(params.windowHandle) : null,
+ region: params.region && typeof params.region === 'object' ? { ...params.region } : null,
+ captureMode: params.captureMode || null,
+ captureTrusted: typeof params.captureTrusted === 'boolean' ? params.captureTrusted : null,
+ captureProvider: params.captureProvider || null,
+ captureCapability: params.captureCapability || null,
+ captureDegradedReason: params.captureDegradedReason || null,
+ captureNonDisruptive: typeof params.captureNonDisruptive === 'boolean' ? params.captureNonDisruptive : null,
+ captureBackgroundRequested: typeof params.captureBackgroundRequested === 'boolean' ? params.captureBackgroundRequested : null
+ };
+}
+
/**
* Create a new inspect region object
* @param {Object} params - Region parameters
@@ -61,7 +109,10 @@ function createInspectRegion(params) {
role: params.role || params.controlType || 'unknown',
confidence: typeof params.confidence === 'number' ? params.confidence : 0.5,
source: params.source || 'unknown',
- timestamp: params.timestamp || Date.now()
+ timestamp: params.timestamp || Date.now(),
+ clickPoint: params.clickPoint || null,
+ runtimeId: params.runtimeId || null,
+ coordinateSpace: params.coordinateSpace || 'screen-physical'
};
}
@@ -203,21 +254,54 @@ function findRegionAtPoint(x, y, regions) {
* @returns {Object} AI-friendly format
*/
function formatRegionForAI(region) {
+ const center = region.clickPoint
+ ? { x: region.clickPoint.x, y: region.clickPoint.y }
+ : {
+ x: Math.round(region.bounds.x + region.bounds.width / 2),
+ y: Math.round(region.bounds.y + region.bounds.height / 2)
+ };
return {
id: region.id,
label: region.label,
text: region.text,
role: region.role,
confidence: region.confidence,
- center: {
- x: Math.round(region.bounds.x + region.bounds.width / 2),
- y: Math.round(region.bounds.y + region.bounds.height / 2)
- },
+ center,
bounds: region.bounds
};
}
+/**
+ * Resolve a region target from the regions array
+ * Supports targetRegionId (stable) or targetRegionIndex (display order)
+ * @param {Object} target - { targetRegionId?, targetRegionIndex? }
+ * @param {InspectRegion[]} regions - Current regions array
+ * @returns {{ region: InspectRegion, clickX: number, clickY: number } | null}
+ */
+function resolveRegionTarget(target, regions) {
+ if (!target || !regions || regions.length === 0) return null;
+
+ let region = null;
+ if (target.targetRegionId) {
+ region = regions.find(r => r.id === target.targetRegionId);
+ } else if (typeof target.targetRegionIndex === 'number') {
+ region = regions[target.targetRegionIndex];
+ }
+ if (!region) return null;
+
+ // Prefer clickPoint from UIA, fall back to bounds center
+ const clickX = region.clickPoint
+ ? region.clickPoint.x
+ : Math.round(region.bounds.x + region.bounds.width / 2);
+ const clickY = region.clickPoint
+ ? region.clickPoint.y
+ : Math.round(region.bounds.y + region.bounds.height / 2);
+
+ return { region, clickX, clickY };
+}
+
module.exports = {
+ createVisualFrame,
createInspectRegion,
createWindowContext,
createActionTrace,
@@ -226,5 +310,6 @@ module.exports = {
isPointInRegion,
findClosestRegion,
findRegionAtPoint,
- formatRegionForAI
+ formatRegionForAI,
+ resolveRegionTarget
};
diff --git a/src/shared/liku-home.js b/src/shared/liku-home.js
new file mode 100644
index 00000000..37edc107
--- /dev/null
+++ b/src/shared/liku-home.js
@@ -0,0 +1,97 @@
+/**
+ * Centralized Liku home directory management.
+ *
+ * Single source of truth for the ~/.liku/ path and its subdirectory structure.
+ * Handles one-time migration from the legacy ~/.liku-cli/ layout.
+ *
+ * Migration strategy: COPY, never move. Old ~/.liku-cli/ remains as fallback.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const LIKU_HOME = path.resolve(process.env.LIKU_HOME_OVERRIDE || path.join(os.homedir(), '.liku'));
+const LIKU_HOME_OLD = path.resolve(process.env.LIKU_HOME_OLD_OVERRIDE || path.join(os.homedir(), '.liku-cli'));
+
+/**
+ * Ensure the full ~/.liku/ directory tree exists.
+ * Safe to call multiple times (idempotent).
+ */
+function ensureLikuStructure() {
+ const dirs = [
+ '', // ~/.liku/ itself
+ 'memory/notes', // Phase 1: Agentic memory
+ 'skills', // Phase 4: Skill router
+ 'tools/dynamic', // Phase 3: Dynamic tool sandbox
+ 'tools/proposed', // Phase 3b: Staging for AI-proposed tools (quarantine)
+ 'telemetry/logs', // Phase 2: RLVR telemetry
+ 'traces' // Agent trace writer
+ ];
+ for (const d of dirs) {
+ const fullPath = path.join(LIKU_HOME, d);
+ if (!fs.existsSync(fullPath)) {
+ fs.mkdirSync(fullPath, { recursive: true, mode: 0o700 });
+ }
+ }
+}
+
+/**
+ * Copy (not move) JSON config files from ~/.liku-cli/ to ~/.liku/
+ * if the target doesn't already exist.
+ */
+function migrateIfNeeded() {
+ const filesToMigrate = [
+ 'preferences.json',
+ 'conversation-history.json',
+ 'copilot-token.json',
+ 'copilot-runtime-state.json',
+ 'model-preference.json'
+ ];
+
+ for (const file of filesToMigrate) {
+ const oldPath = path.join(LIKU_HOME_OLD, file);
+ const newPath = path.join(LIKU_HOME, file);
+ try {
+ if (fs.existsSync(oldPath) && !fs.existsSync(newPath)) {
+ fs.copyFileSync(oldPath, newPath);
+ console.log(`[Liku] Migrated ${file} to ~/.liku/`);
+ }
+ } catch (err) {
+ console.warn(`[Liku] Could not migrate ${file}: ${err.message}`);
+ }
+ }
+
+ // Migrate traces directory if it exists
+ const oldTraces = path.join(LIKU_HOME_OLD, 'traces');
+ const newTraces = path.join(LIKU_HOME, 'traces');
+ try {
+ if (fs.existsSync(oldTraces) && fs.statSync(oldTraces).isDirectory()) {
+ const traceFiles = fs.readdirSync(oldTraces);
+ for (const tf of traceFiles) {
+ const src = path.join(oldTraces, tf);
+ const dst = path.join(newTraces, tf);
+ if (!fs.existsSync(dst) && fs.statSync(src).isFile()) {
+ fs.copyFileSync(src, dst);
+ }
+ }
+ }
+ } catch (err) {
+ console.warn(`[Liku] Could not migrate traces: ${err.message}`);
+ }
+}
+
+/**
+ * Return the canonical home directory path.
+ */
+function getLikuHome() {
+ return LIKU_HOME;
+}
+
+module.exports = {
+ LIKU_HOME,
+ LIKU_HOME_OLD,
+ ensureLikuStructure,
+ migrateIfNeeded,
+ getLikuHome
+};
diff --git a/src/shared/project-identity.js b/src/shared/project-identity.js
new file mode 100644
index 00000000..bc6734e2
--- /dev/null
+++ b/src/shared/project-identity.js
@@ -0,0 +1,172 @@
+const fs = require('fs');
+const path = require('path');
+
+function normalizePath(value) {
+ if (!value) return null;
+ const resolved = path.resolve(String(value));
+ let normalized = resolved;
+ try {
+ normalized = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved);
+ } catch {
+ normalized = resolved;
+ }
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
+}
+
+function normalizeName(value) {
+ return String(value || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-');
+}
+
+function walkUpFor(startPath, predicate) {
+ let current = normalizePath(startPath || process.cwd());
+ while (current) {
+ if (predicate(current)) return current;
+ const parent = path.dirname(current);
+ if (!parent || parent === current) break;
+ current = parent;
+ }
+ return null;
+}
+
+function safeReadJson(filePath) {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+function parseGitDirectory(rootPath) {
+ const gitPath = path.join(rootPath, '.git');
+ if (!fs.existsSync(gitPath)) return null;
+ try {
+ const stat = fs.statSync(gitPath);
+ if (stat.isDirectory()) return gitPath;
+ const text = fs.readFileSync(gitPath, 'utf8');
+ const match = text.match(/gitdir:\s*(.+)/i);
+ if (!match) return null;
+ return normalizePath(path.resolve(rootPath, match[1].trim()));
+ } catch {
+ return null;
+ }
+}
+
+function readGitConfig(gitDir) {
+ if (!gitDir) return null;
+ const configPath = path.join(gitDir, 'config');
+ if (!fs.existsSync(configPath)) return null;
+ try {
+ return fs.readFileSync(configPath, 'utf8');
+ } catch {
+ return null;
+ }
+}
+
+function extractGitRemote(configText) {
+ const text = String(configText || '');
+ const originMatch = text.match(/\[remote\s+"origin"\][^[]*?url\s*=\s*(.+)/i);
+ if (originMatch?.[1]) return originMatch[1].trim();
+ const anyMatch = text.match(/\[remote\s+"[^"]+"\][^[]*?url\s*=\s*(.+)/i);
+ return anyMatch?.[1] ? anyMatch[1].trim() : null;
+}
+
+function extractRepoNameFromRemote(remote) {
+ const trimmed = String(remote || '').trim();
+ if (!trimmed) return null;
+ const last = trimmed.split(/[/:\\]/).filter(Boolean).pop() || '';
+ return last.replace(/\.git$/i, '') || null;
+}
+
+function buildAliases(parts) {
+ const values = new Set();
+ for (const part of parts) {
+ if (!part) continue;
+ const raw = String(part).trim();
+ if (!raw) continue;
+ values.add(raw);
+ values.add(normalizeName(raw));
+ }
+ return [...values].filter(Boolean);
+}
+
+function detectProjectRoot(startPath = process.cwd()) {
+ return walkUpFor(startPath, (candidate) => fs.existsSync(path.join(candidate, 'package.json')))
+ || normalizePath(startPath || process.cwd());
+}
+
+function resolveProjectIdentity(options = {}) {
+ const cwd = normalizePath(options.cwd || process.cwd());
+ const projectRoot = detectProjectRoot(cwd);
+ const packagePath = path.join(projectRoot, 'package.json');
+ const packageJson = safeReadJson(packagePath) || {};
+ const gitDir = parseGitDirectory(projectRoot);
+ const gitRemote = extractGitRemote(readGitConfig(gitDir));
+ const folderName = path.basename(projectRoot);
+ const packageName = typeof packageJson.name === 'string' ? packageJson.name.trim() : null;
+ const remoteRepoName = extractRepoNameFromRemote(gitRemote);
+ const repoName = remoteRepoName || packageName || folderName;
+ const aliases = buildAliases([repoName, packageName, folderName]);
+
+ return {
+ cwd,
+ projectRoot,
+ folderName,
+ packageName,
+ packageVersion: typeof packageJson.version === 'string' ? packageJson.version.trim() : null,
+ repoName,
+ normalizedRepoName: normalizeName(packageName || repoName || folderName),
+ gitRemote,
+ aliases
+ };
+}
+
+function isPathInside(parentPath, childPath) {
+ const parent = normalizePath(parentPath);
+ const child = normalizePath(childPath);
+ if (!parent || !child) return false;
+ const relative = path.relative(parent, child);
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
+}
+
+function validateProjectIdentity(options = {}) {
+ const detected = resolveProjectIdentity({ cwd: options.cwd });
+ const expectedProjectRoot = options.expectedProjectRoot ? normalizePath(options.expectedProjectRoot) : null;
+ const expectedRepo = options.expectedRepo ? normalizeName(options.expectedRepo) : null;
+ const errors = [];
+
+ if (expectedProjectRoot && !isPathInside(expectedProjectRoot, detected.cwd)) {
+ errors.push(`cwd ${detected.cwd} is outside expected project ${expectedProjectRoot}`);
+ }
+
+ if (expectedProjectRoot && detected.projectRoot !== expectedProjectRoot) {
+ errors.push(`detected root ${detected.projectRoot} does not match expected project ${expectedProjectRoot}`);
+ }
+
+ if (expectedRepo) {
+ const normalizedAliases = new Set(detected.aliases.map((alias) => normalizeName(alias)));
+ if (!normalizedAliases.has(expectedRepo)) {
+ errors.push(`detected repo ${detected.repoName} does not match expected repo ${options.expectedRepo}`);
+ }
+ }
+
+ return {
+ ok: errors.length === 0,
+ errors,
+ expected: {
+ projectRoot: expectedProjectRoot,
+ repo: options.expectedRepo || null
+ },
+ detected
+ };
+}
+
+module.exports = {
+ detectProjectRoot,
+ normalizePath,
+ normalizeName,
+ resolveProjectIdentity,
+ validateProjectIdentity
+};
\ No newline at end of file
diff --git a/src/shared/token-counter.js b/src/shared/token-counter.js
new file mode 100644
index 00000000..49d36b43
--- /dev/null
+++ b/src/shared/token-counter.js
@@ -0,0 +1,45 @@
+/**
+ * Token Counter — accurate BPE tokenization via js-tiktoken
+ *
+ * Uses cl100k_base encoding (standard for GPT-4o / o1 family).
+ * Pure JavaScript — no native bindings, safe for Electron + CLI.
+ */
+
+const { getEncoding } = require('js-tiktoken');
+
+let _enc;
+
+function getEncoder() {
+ if (!_enc) {
+ _enc = getEncoding('cl100k_base');
+ }
+ return _enc;
+}
+
+/**
+ * Count tokens in a string using BPE tokenization.
+ * @param {string} text
+ * @returns {number}
+ */
+function countTokens(text) {
+ if (!text) return 0;
+ return getEncoder().encode(text).length;
+}
+
+/**
+ * Truncate text to fit within a token budget.
+ * Returns the largest prefix that stays within the budget.
+ * @param {string} text
+ * @param {number} maxTokens
+ * @returns {string}
+ */
+function truncateToTokenBudget(text, maxTokens) {
+ if (!text) return '';
+ const enc = getEncoder();
+ const tokens = enc.encode(text);
+ if (tokens.length <= maxTokens) return text;
+ const truncated = tokens.slice(0, maxTokens);
+ return enc.decode(truncated);
+}
+
+module.exports = { countTokens, truncateToTokenBudget };
diff --git a/ui-automation-state.json b/ui-automation-state.json
new file mode 100644
index 00000000..c67f82b7
--- /dev/null
+++ b/ui-automation-state.json
@@ -0,0 +1,35 @@
+{
+ "status": "verified",
+ "verification_summary": "Verified that the windows_uia JSON schema matches the unified UIElement interface. The Node.js UIProvider correctly parses the OS-specific JSON into the unified UIElement interface, ensuring all required properties (id, name, role, bounds, isClickable, isFocusable, children) are properly mapped and typed.",
+ "windows_uia": {
+ "status": "completed",
+ "technology": "C# .NET Console Application (System.Windows.Automation)",
+ "prototype_code": "using System;\nusing System.Collections.Generic;\nusing System.Runtime.InteropServices;\nusing System.Text.Json;\nusing System.Windows.Automation;\n\nnamespace UIAWrapper\n{\n class Program\n {\n [DllImport(\"user32.dll\")]\n static extern IntPtr GetForegroundWindow();\n\n static void Main(string[] args)\n {\n IntPtr handle = GetForegroundWindow();\n if (handle == IntPtr.Zero) return;\n\n AutomationElement root = AutomationElement.FromHandle(handle);\n var node = BuildTree(root);\n\n string json = JsonSerializer.Serialize(node, new JsonSerializerOptions { WriteIndented = true });\n Console.WriteLine(json);\n }\n\n static UIANode BuildTree(AutomationElement element)\n {\n var node = new UIANode\n {\n id = element.Current.AutomationId,\n name = element.Current.Name,\n role = element.Current.ControlType.ProgrammaticName.Replace(\"ControlType.\", \"\"),\n bounds = new Bounds\n {\n x = element.Current.BoundingRectangle.X,\n y = element.Current.BoundingRectangle.Y,\n width = element.Current.BoundingRectangle.Width,\n height = element.Current.BoundingRectangle.Height\n },\n isClickable = (bool)element.GetCurrentPropertyValue(AutomationElement.IsInvokePatternAvailableProperty) || element.Current.IsKeyboardFocusable,\n isFocusable = element.Current.IsKeyboardFocusable,\n children = new List
()\n };\n\n var walker = TreeWalker.ControlViewWalker;\n var child = walker.GetFirstChild(element);\n while (child != null)\n {\n try\n {\n if (!child.Current.IsOffscreen)\n {\n node.children.Add(BuildTree(child));\n }\n }\n catch (ElementNotAvailableException) { }\n \n child = walker.GetNextSibling(child);\n }\n\n return node;\n }\n }\n\n class UIANode\n {\n public string id { get; set; }\n public string name { get; set; }\n public string role { get; set; }\n public Bounds bounds { get; set; }\n public bool isClickable { get; set; }\n public bool isFocusable { get; set; }\n public List children { get; set; }\n }\n\n class Bounds\n {\n public double x { get; set; }\n public double y { get; set; }\n public double width { get; set; }\n public double height { get; set; }\n }\n}",
+ "json_schema": "{\n \"type\": \"object\",\n \"properties\": {\n \"id\": { \"type\": \"string\" },\n \"name\": { \"type\": \"string\" },\n \"role\": { \"type\": \"string\" },\n \"bounds\": {\n \"type\": \"object\",\n \"properties\": {\n \"x\": { \"type\": \"number\" },\n \"y\": { \"type\": \"number\" },\n \"width\": { \"type\": \"number\" },\n \"height\": { \"type\": \"number\" }\n },\n \"required\": [\"x\", \"y\", \"width\", \"height\"]\n },\n \"isClickable\": { \"type\": \"boolean\" },\n \"isFocusable\": { \"type\": \"boolean\" },\n \"children\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#\" }\n }\n },\n \"required\": [\"id\", \"name\", \"role\", \"bounds\", \"isClickable\", \"isFocusable\", \"children\"]\n}"
+ },
+ "macos_ax": {
+ "status": "pending",
+ "technology": null,
+ "prototype_code": null,
+ "json_schema": null
+ },
+ "node_bridge": {
+ "status": "completed",
+ "interface_code": "const { spawn } = require('child_process');\r\nconst path = require('path');\r\n\r\n/**\r\n * @typedef {Object} Bounds\r\n * @property {number} x\r\n * @property {number} y\r\n * @property {number} width\r\n * @property {number} height\r\n */\r\n\r\n/**\r\n * @typedef {Object} UIElement\r\n * @property {string} id\r\n * @property {string} name\r\n * @property {string} role\r\n * @property {Bounds} bounds\r\n * @property {boolean} isClickable\r\n * @property {boolean} isFocusable\r\n * @property {UIElement[]} children\r\n */\r\n\r\nclass UIProvider {\r\n constructor() {\r\n // Assuming the binary is compiled to bin/windows-uia.exe relative to project root\r\n this.binaryPath = path.join(__dirname, '..', '..', '..', '..', 'bin', 'windows-uia.exe');\r\n }\r\n\r\n /**\r\n * Fetches the UI tree from the native binary.\r\n * @returns {Promise}\r\n */\r\n async getUITree() {\r\n return new Promise((resolve, reject) => {\r\n const child = spawn(this.binaryPath);\r\n let output = '';\r\n let errorOutput = '';\r\n\r\n child.stdout.on('data', (data) => {\r\n output += data.toString();\r\n });\r\n\r\n child.stderr.on('data', (data) => {\r\n errorOutput += data.toString();\r\n });\r\n\r\n child.on('close', (code) => {\r\n if (code !== 0) {\r\n return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));\r\n }\r\n\r\n try {\r\n const parsed = JSON.parse(output);\r\n const uiTree = this.parseNode(parsed);\r\n resolve(uiTree);\r\n } catch (err) {\r\n reject(new Error(`Failed to parse JSON output: ${err.message}`));\r\n }\r\n });\r\n \r\n child.on('error', (err) => {\r\n reject(new Error(`Failed to start subprocess: ${err.message}`));\r\n });\r\n });\r\n }\r\n\r\n /**\r\n * Parses the OS-specific JSON node into a unified UIElement.\r\n * @param {Object} node\r\n * @returns {UIElement}\r\n */\r\n parseNode(node) {\r\n return {\r\n id: node.id || '',\r\n name: node.name || '',\r\n role: node.role || '',\r\n bounds: {\r\n x: node.bounds?.x || 0,\r\n y: node.bounds?.y || 0,\r\n width: node.bounds?.width || 0,\r\n height: node.bounds?.height || 0\r\n },\r\n isClickable: !!node.isClickable,\r\n isFocusable: !!node.isFocusable,\r\n children: (node.children || []).map(child => this.parseNode(child))\r\n };\r\n }\r\n}\r\n\r\nmodule.exports = { UIProvider };\r\n",
+ "ipc_code": "const { ipcMain } = require('electron');\nconst { UIProvider } = require('./ui-provider');\n\nfunction setupIPC() {\n const uiProvider = new UIProvider();\n \n ipcMain.handle('get-ui-tree', async () => {\n try {\n const tree = await uiProvider.getUITree();\n return { success: true, data: tree };\n } catch (error) {\n return { success: false, error: error.message };\n }\n });\n}\n\nmodule.exports = { setupIPC };"
+ },
+ "ai_context_strategy": {
+ "status": "completed",
+ "summary": "AI messages now include a grounded Semantic DOM section from UIProvider snapshots with pruning, freshness gating, and character limits.",
+ "rules": {
+ "maxDepth": 4,
+ "maxNodes": 120,
+ "maxChars": 3500,
+ "maxAgeMs": 5000
+ }
+ },
+ "electron_overlay": {
+ "status": "completed",
+ "rendering_code": "Main process now prefers cached UIProvider regions for overlay update-inspect-regions and falls back to UIWatcher regions when provider data is stale/unavailable."
+ }
+}
\ No newline at end of file
diff --git a/ultimate-ai-system/liku/cli/src/bin.ts b/ultimate-ai-system/liku/cli/src/bin.ts
index 0e1f7213..ef236fee 100644
--- a/ultimate-ai-system/liku/cli/src/bin.ts
+++ b/ultimate-ai-system/liku/cli/src/bin.ts
@@ -1,100 +1,73 @@
#!/usr/bin/env node
-import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
-import { join, resolve } from 'node:path';
-import { AIStreamParser, type CheckpointState } from '@liku/core';
+/**
+ * @liku/cli entry point.
+ *
+ * Uses the loader-based command system:
+ * SlashCommandProcessor ← orchestrator
+ * └─ BuildCommandLoader ← built-in commands (LikuCommands)
+ * └─ (future: FileCommandLoader for TOML, McpLoader, etc.)
+ */
+
+import { SlashCommandProcessor, BuildCommandLoader } from './commands/index.js';
const colors = { reset: '\x1b[0m', bright: '\x1b[1m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m' };
const log = (msg: string, c: keyof typeof colors = 'reset') => console.log(`${colors[c]}${msg}${colors.reset}`);
-const logSuccess = (msg: string) => log(`✅ ${msg}`, 'green');
-const logError = (msg: string) => log(`❌ ${msg}`, 'red');
-const logInfo = (msg: string) => log(`ℹ️ ${msg}`, 'cyan');
-const logWarning = (msg: string) => log(`⚠️ ${msg}`, 'yellow');
-function showHelp() {
- console.log(`\n${colors.bright}${colors.cyan}Liku AI System CLI${colors.reset}\n
-Usage: liku [options]
+function showHelp(commands: readonly import('./commands/types.js').SlashCommand[]) {
+ console.log(`\n${colors.bright}${colors.cyan}Liku AI System CLI${colors.reset}\n`);
+ console.log('Usage: liku [options]\n');
+ console.log(`${colors.bright}Commands:${colors.reset}`);
-Commands:
- init [path] Initialize a new Liku-enabled project
- checkpoint Create a checkpoint for session handover
- status Show current project status
- parse Parse an AI output file for structured tags
- help Show this help message\n`);
+ const maxLen = Math.max(...commands.map(c => c.name.length + (c.argHint?.length ?? 0)));
+ for (const cmd of commands) {
+ const label = cmd.argHint ? `${cmd.name} ${cmd.argHint}` : cmd.name;
+ const pad = ' '.repeat(maxLen - label.length + 4);
+ console.log(` ${colors.cyan}${label}${colors.reset}${pad}${cmd.description}`);
+ }
+ console.log(`\n${colors.bright}Options:${colors.reset}`);
+ console.log(' --help, -h Show this help message');
+ console.log(' --version, -v Show version');
+ console.log(' --json Output results as JSON');
+ console.log(' --quiet, -q Suppress non-essential output\n');
}
-function findProjectRoot(start = process.cwd()): string | null {
- let p = resolve(start);
- while (p !== resolve(p, '..')) {
- if (existsSync(join(p, '.ai', 'manifest.json'))) return p;
- p = resolve(p, '..');
+async function main() {
+ const ac = new AbortController();
+
+ // Assemble loaders — add future loaders here (FileCommandLoader, McpLoader, etc.)
+ const loaders = [new BuildCommandLoader()];
+
+ const processor = await SlashCommandProcessor.create(loaders, ac.signal);
+ const { command, context } = SlashCommandProcessor.parseArgs(process.argv);
+
+ if (context.flags.version) {
+ console.log('liku (monorepo) 0.1.0');
+ return;
}
- return null;
-}
-function initProject(target = '.') {
- const projectPath = resolve(target);
- log(`\n🚀 Initializing Liku AI System at: ${projectPath}\n`, 'bright');
- if (existsSync(join(projectPath, '.ai', 'manifest.json'))) { logWarning('Project already initialized.'); return; }
- for (const dir of ['.ai/context', '.ai/instructions', '.ai/logs', 'src', 'tests', 'packages']) {
- const full = join(projectPath, dir);
- if (!existsSync(full)) { mkdirSync(full, { recursive: true }); logInfo(`Created: ${dir}/`); }
+ if (context.flags.help || !command) {
+ showHelp(processor.getCommands());
+ return;
}
- const manifest = { version: '3.1.0', project_root: '.', system_rules: { filesystem_security: { immutable_paths: ['.ai/manifest.json'], writable_paths: ['src/**', 'tests/**', 'packages/**'] } }, agent_profile: { default: 'defensive', token_limit_soft_cap: 32000, context_strategy: 'checkpoint_handover' }, verification: { strategies: { typescript: { tier1_fast: 'pnpm test -- --related ${files}', tier2_preflight: 'pnpm build && pnpm test' } } }, memory: { checkpoint_file: '.ai/context/checkpoint.xml', provenance_log: '.ai/logs/provenance.csv' } };
- writeFileSync(join(projectPath, '.ai', 'manifest.json'), JSON.stringify(manifest, null, 2));
- logSuccess('Created: .ai/manifest.json');
- writeFileSync(join(projectPath, '.ai', 'context', 'checkpoint.xml'), '\n');
- logSuccess('Created: .ai/context/checkpoint.xml');
- writeFileSync(join(projectPath, '.ai', 'logs', 'provenance.csv'), 'timestamp,action,path,agent,checksum,parent_checksum,reason\n');
- logSuccess('Created: .ai/logs/provenance.csv');
- log(`\n${colors.green}${colors.bright}✨ Project initialized!${colors.reset}\n`);
-}
-function createCheckpoint(context?: string) {
- const root = findProjectRoot();
- if (!root) { logError('Not in a Liku project.'); process.exit(1); }
- const ts = new Date().toISOString();
- const xml = `\n${ts}${context ?? 'Manual checkpoint'}`;
- writeFileSync(join(root, '.ai', 'context', 'checkpoint.xml'), xml);
- logSuccess(`Checkpoint created: ${ts}`);
-}
+ const result = await processor.execute(command, context);
-function showStatus() {
- const root = findProjectRoot();
- if (!root) { logError('Not in a Liku project.'); process.exit(1); }
- log(`\n${colors.bright}${colors.cyan}Liku Project Status${colors.reset}\n`);
- log(`Project Root: ${root}`, 'bright');
- const mp = join(root, '.ai', 'manifest.json');
- if (existsSync(mp)) { const m = JSON.parse(readFileSync(mp, 'utf-8')); logSuccess(`Manifest: v${m.version}`); logInfo(`Agent Profile: ${m.agent_profile?.default}`); logInfo(`Context Strategy: ${m.agent_profile?.context_strategy}`); }
- if (existsSync(join(root, '.ai', 'context', 'checkpoint.xml'))) logSuccess('Checkpoint file exists');
- else logWarning('No checkpoint found');
- const pp = join(root, '.ai', 'logs', 'provenance.csv');
- if (existsSync(pp)) { const lines = readFileSync(pp, 'utf-8').trim().split('\n').length - 1; logSuccess(`Provenance log: ${lines} entries`); }
- const ip = join(root, '.ai', 'instructions');
- if (existsSync(ip)) { const files = readdirSync(ip); logSuccess(`Instructions: ${files.length} file(s)`); files.forEach(f => logInfo(` - ${f}`)); }
- console.log();
-}
+ if (!result) {
+ log(`Unknown command: ${command}`, 'red');
+ showHelp(processor.getCommands());
+ process.exit(1);
+ }
-function parseFile(filePath: string) {
- if (!existsSync(filePath)) { logError(`File not found: ${filePath}`); process.exit(1); }
- const content = readFileSync(filePath, 'utf-8');
- const parser = new AIStreamParser();
- log(`\n${colors.bright}Parsing: ${filePath}${colors.reset}\n`);
- let count = 0;
- parser.on('checkpoint', () => { count++; log('📍 Checkpoint', 'cyan'); });
- parser.on('file_change', ({ path }) => { count++; log(`📝 File Change: ${path}`, 'green'); });
- parser.on('verify', (cmd) => { count++; log(`🔍 Verify: ${cmd}`, 'yellow'); });
- parser.on('analysis', ({ type }) => { count++; log(`📊 Analysis (${type})`, 'cyan'); });
- parser.on('hypothesis', () => { count++; log('💡 Hypothesis', 'cyan'); });
- parser.feed(content);
- log(`\n${colors.bright}Found ${count} structured event(s)${colors.reset}\n`);
-}
+ if (context.flags.json && result.data !== undefined) {
+ console.log(JSON.stringify(result.data, null, 2));
+ } else if (result.message) {
+ log(result.message, result.success ? 'green' : 'red');
+ }
-const args = process.argv.slice(2);
-switch (args[0]) {
- case 'init': initProject(args[1]); break;
- case 'checkpoint': createCheckpoint(args[1]); break;
- case 'status': showStatus(); break;
- case 'parse': if (!args[1]) { logError('Provide file path'); process.exit(1); } parseFile(args[1]); break;
- case 'help': case '--help': case '-h': case undefined: showHelp(); break;
- default: logError(`Unknown: ${args[0]}`); showHelp(); process.exit(1);
+ if (!result.success) process.exit(1);
}
+
+main().catch((err: Error) => {
+ log(err.message, 'red');
+ process.exit(1);
+});
diff --git a/ultimate-ai-system/liku/cli/src/commands/BuildCommandLoader.ts b/ultimate-ai-system/liku/cli/src/commands/BuildCommandLoader.ts
new file mode 100644
index 00000000..4ce15013
--- /dev/null
+++ b/ultimate-ai-system/liku/cli/src/commands/BuildCommandLoader.ts
@@ -0,0 +1,19 @@
+/**
+ * Loads the hard-coded built-in commands that ship with @liku/cli.
+ *
+ * This is the simplest loader — it just returns the LIKU_COMMANDS
+ * registry as-is. Keeping it behind the ICommandLoader interface
+ * means the processor treats all sources uniformly: built-in,
+ * user TOML, project TOML, MCP, extensions — same contract.
+ */
+
+import type { ICommandLoader, SlashCommand } from './types.js';
+import { LIKU_COMMANDS } from './LikuCommands.js';
+
+export class BuildCommandLoader implements ICommandLoader {
+ async loadCommands(_signal: AbortSignal): Promise {
+ // Return a mutable copy so the processor can rename on conflict
+ // without mutating the frozen registry.
+ return LIKU_COMMANDS.map((cmd) => ({ ...cmd }));
+ }
+}
diff --git a/ultimate-ai-system/liku/cli/src/commands/LikuCommands.ts b/ultimate-ai-system/liku/cli/src/commands/LikuCommands.ts
new file mode 100644
index 00000000..1029ea37
--- /dev/null
+++ b/ultimate-ai-system/liku/cli/src/commands/LikuCommands.ts
@@ -0,0 +1,208 @@
+/**
+ * Liku command registry — defines all built-in commands.
+ *
+ * This is the single source of truth for command metadata.
+ * Each entry maps a command name to its description, arg hint,
+ * and action implementation.
+ *
+ * Automation commands delegate to the existing JS modules in
+ * src/cli/commands/ via dynamic import. AI-system commands
+ * (init, checkpoint, status, parse) are implemented inline
+ * since they live in this TypeScript package.
+ */
+
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
+import { join, resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+import { AIStreamParser, type CheckpointState } from '@liku/core';
+import { CommandKind, type SlashCommand, type CommandContext, type CommandResult } from './types.js';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const require = createRequire(import.meta.url);
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function findProjectRoot(start = process.cwd()): string | null {
+ let p = resolve(start);
+ while (p !== resolve(p, '..')) {
+ if (existsSync(join(p, '.ai', 'manifest.json'))) return p;
+ p = resolve(p, '..');
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// AI-system command actions
+// ---------------------------------------------------------------------------
+
+async function initAction(ctx: CommandContext): Promise {
+ const target = ctx.args[0] ?? '.';
+ const projectPath = resolve(target);
+
+ if (existsSync(join(projectPath, '.ai', 'manifest.json'))) {
+ return { success: false, message: 'Project already initialized.' };
+ }
+
+ for (const dir of ['.ai/context', '.ai/instructions', '.ai/logs', 'src', 'tests', 'packages']) {
+ const full = join(projectPath, dir);
+ if (!existsSync(full)) mkdirSync(full, { recursive: true });
+ }
+
+ const manifest = {
+ version: '3.1.0',
+ project_root: '.',
+ system_rules: {
+ filesystem_security: {
+ immutable_paths: ['.ai/manifest.json'],
+ writable_paths: ['src/**', 'tests/**', 'packages/**'],
+ },
+ },
+ agent_profile: {
+ default: 'defensive',
+ token_limit_soft_cap: 32000,
+ context_strategy: 'checkpoint_handover',
+ },
+ verification: {
+ strategies: {
+ typescript: {
+ tier1_fast: 'pnpm test -- --related ${files}',
+ tier2_preflight: 'pnpm build && pnpm test',
+ },
+ },
+ },
+ memory: {
+ checkpoint_file: '.ai/context/checkpoint.xml',
+ provenance_log: '.ai/logs/provenance.csv',
+ },
+ };
+
+ writeFileSync(join(projectPath, '.ai', 'manifest.json'), JSON.stringify(manifest, null, 2));
+ writeFileSync(
+ join(projectPath, '.ai', 'context', 'checkpoint.xml'),
+ '\n',
+ );
+ writeFileSync(
+ join(projectPath, '.ai', 'logs', 'provenance.csv'),
+ 'timestamp,action,path,agent,checksum,parent_checksum,reason\n',
+ );
+
+ return { success: true, message: `Project initialized at ${projectPath}` };
+}
+
+async function checkpointAction(_ctx: CommandContext): Promise {
+ const root = findProjectRoot();
+ if (!root) return { success: false, message: 'No Liku project found. Run liku init first.' };
+
+ const cpPath = join(root, '.ai', 'context', 'checkpoint.xml');
+ const checkpoint: CheckpointState = {
+ timestamp: new Date().toISOString(),
+ context: `Session checkpoint from ${root}`,
+ pendingTasks: [],
+ modifiedFiles: [],
+ };
+
+ writeFileSync(cpPath, JSON.stringify(checkpoint, null, 2));
+ return { success: true, message: `Checkpoint saved: ${cpPath}`, data: checkpoint };
+}
+
+async function statusAction(_ctx: CommandContext): Promise {
+ const root = findProjectRoot();
+ if (!root) return { success: false, message: 'No Liku project found.' };
+
+ const manifestPath = join(root, '.ai', 'manifest.json');
+ const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf-8'));
+
+ const cpPath = join(root, '.ai', 'context', 'checkpoint.xml');
+ const hasCheckpoint = existsSync(cpPath);
+
+ return {
+ success: true,
+ message: `Project root: ${root}`,
+ data: { root, manifest, hasCheckpoint },
+ };
+}
+
+async function parseAction(ctx: CommandContext): Promise {
+ const file = ctx.args[0];
+ if (!file) return { success: false, message: 'Usage: liku parse ' };
+ if (!existsSync(file)) return { success: false, message: `File not found: ${file}` };
+
+ const content = readFileSync(file, 'utf-8');
+ const parser = new AIStreamParser();
+ const events: Array<{ event: string; data: unknown }> = [];
+ parser.on('analysis', (d: unknown) => events.push({ event: 'analysis', data: d }));
+ parser.on('hypothesis', (d: unknown) => events.push({ event: 'hypothesis', data: d }));
+ parser.on('file_change', (d: unknown) => events.push({ event: 'file_change', data: d }));
+ parser.on('checkpoint', (d: unknown) => events.push({ event: 'checkpoint', data: d }));
+ parser.on('verify', (d: unknown) => events.push({ event: 'verify', data: d }));
+ parser.feed(content);
+
+ return { success: true, message: `Parsed ${events.length} events from ${file}`, data: events };
+}
+
+// ---------------------------------------------------------------------------
+// Automation command factory — wraps existing src/cli/commands/*.js modules
+// ---------------------------------------------------------------------------
+
+/**
+ * Creates a SlashCommand that delegates to the existing CommonJS module.
+ * The module path is resolved at call time so it only fails if actually invoked.
+ */
+function automationCommand(
+ name: string,
+ description: string,
+ argHint?: string,
+): SlashCommand {
+ return {
+ name,
+ description,
+ kind: CommandKind.BUILT_IN,
+ argHint,
+ action: async (ctx: CommandContext): Promise => {
+ // Resolve relative to the Electron project root, not the monorepo
+ // __dirname at runtime = ultimate-ai-system/liku/cli/dist/commands (5 levels)
+ const cliCommandsDir = resolve(__dirname, '../../../../../src/cli/commands');
+ const modPath = join(cliCommandsDir, `${name}.js`);
+
+ if (!existsSync(modPath)) {
+ return { success: false, message: `Automation module not found: ${modPath}` };
+ }
+
+ // Dynamic require of CJS module from ESM context
+ const mod = require(modPath) as { run: (args: string[], opts: Record) => Promise };
+ return mod.run(ctx.args, { ...ctx.flags, ...ctx.options });
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Full registry
+// ---------------------------------------------------------------------------
+
+/** All built-in Liku commands. */
+export const LIKU_COMMANDS: readonly SlashCommand[] = Object.freeze([
+ // --- AI system commands ---
+ { name: 'init', description: 'Initialize a new Liku-enabled project', kind: CommandKind.BUILT_IN, argHint: '[path]', action: initAction },
+ { name: 'checkpoint', description: 'Create a checkpoint for session handover', kind: CommandKind.BUILT_IN, action: checkpointAction },
+ { name: 'status', description: 'Show current project status', kind: CommandKind.BUILT_IN, action: statusAction },
+ { name: 'parse', description: 'Parse an AI output file for structured tags', kind: CommandKind.BUILT_IN, argHint: '', action: parseAction },
+
+ // --- Automation commands (delegate to src/cli/commands/*.js) ---
+ automationCommand('start', 'Start the Electron agent with overlay'),
+ automationCommand('click', 'Click element by text or coordinates', ''),
+ automationCommand('find', 'Find UI elements matching criteria', ''),
+ automationCommand('type', 'Type text at current cursor position', ''),
+ automationCommand('keys', 'Send keyboard shortcut', ''),
+ automationCommand('screenshot', 'Capture screenshot', '[path]'),
+ automationCommand('window', 'Focus or list windows', '[title]'),
+ automationCommand('mouse', 'Move mouse to coordinates', ' '),
+ automationCommand('drag', 'Drag from one point to another', '