Compare commits

...

3 Commits

Author SHA1 Message Date
6a87590176 Update shadcn to v4, fix sendHomeRequest call signature, refresh skills lock
- Upgrade shadcn from 3.8.5 to 4.0.8
- Add missing session_id parameter to sendHomeRequest calls in orchestrator
- Update skills-lock.json computed hashes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:34:07 +01:00
cd4644637b Wire journey chat to WS backend and handle end-of-conversation
- Rewrite PromptBuilderChat to use real WS journey mutations with
  button-to-start pattern, loading states, and markdown rendering
- Add isDone state to both PromptBuilderChat and JourneyDialog so
  input is disabled and a confirmation banner shown after prompt generation
- Extract and save promptTemplate via onPromptUpdate when BE sends done=true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:26:21 +01:00
9fd441e7d7 Refactor Local Directory Monitor Agent to two-phase BE-orchestrated architecture
Replace the old single-pass FE file-reader flow (agent_run → agent_data →
agent_complete) with a BE-orchestrated two-phase execution where the BE's LLM
calls filesystem tools on the FE via tool_call/tool_result WS round-trips.

Key changes:
- Remove deprecated file-reader.ts and agent_run/agent_data/agent_complete frames
- Add list_directory, read_file_content, get_file_metadata handlers to DrizzleExecutor
- Migrate journey setup from REST to WebSocket (journey_start/message/reply frames)
- Store agent configs locally in electron-store (no longer on BE)
- Add agent scheduler for periodic auto-trigger via POST /agents/trigger
- Update device_hello to use local agent configs
- Remove fileExtensions from agent config, switch to single directory path
- Add agent.canCreate quota check mutation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:05:08 +01:00
13 changed files with 1222 additions and 1015 deletions

55
package-lock.json generated
View File

@@ -71,34 +71,12 @@
"eslint-plugin-react-hooks": "^4.6.2",
"knip": "^5.85.0",
"postcss": "^8.5.6",
"shadcn": "^3.8.5",
"shadcn": "^4.0.8",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3",
"vite": "^5.4.21"
}
},
"node_modules/@antfu/ni": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz",
"integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansis": "^4.0.0",
"fzf": "^0.5.2",
"package-manager-detector": "^1.3.0",
"tinyexec": "^1.0.1"
},
"bin": {
"na": "bin/na.mjs",
"nci": "bin/nci.mjs",
"ni": "bin/ni.mjs",
"nlx": "bin/nlx.mjs",
"nr": "bin/nr.mjs",
"nun": "bin/nun.mjs",
"nup": "bin/nup.mjs"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -14295,13 +14273,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fzf": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/galactus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/galactus/-/galactus-1.0.0.tgz",
@@ -18872,13 +18843,6 @@
"node": ">=4"
}
},
"node_modules/package-manager-detector": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
"dev": true,
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -21271,13 +21235,12 @@
"license": "ISC"
},
"node_modules/shadcn": {
"version": "3.8.5",
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-3.8.5.tgz",
"integrity": "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.0.8.tgz",
"integrity": "sha512-DVAyeo95TQ/OvaHugLm5V0Dqz3al+dnoP3mZdWWxKJ33IYG1jN5B3sGZyNaYsfzm7JsWokfksSzDl83LnmMing==",
"dev": true,
"license": "MIT",
"dependencies": {
"@antfu/ni": "^25.0.0",
"@babel/core": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/plugin-transform-typescript": "^7.28.0",
@@ -22721,16 +22684,6 @@
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -43,7 +43,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"knip": "^5.85.0",
"postcss": "^8.5.6",
"shadcn": "^3.8.5",
"shadcn": "^4.0.8",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3",
"vite": "^5.4.21"

View File

@@ -4,92 +4,92 @@
"adapt": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "cc2a4e3b438553622819d2e02b996e67c6685a93de8974e3b4189c14a5414237"
"computedHash": "a884f9cc4adb0b3da02d0f8becb1c36245adec7dcc087cd44e6054113755ac6e"
},
"animate": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "d9678700cc105ae1af98763fb4f85168ba398395d241aae336ef421836d9dd82"
"computedHash": "ce0f9cc82930d5c3e674918d363aa095870d70951d136f0f72e252f5954bbc85"
},
"audit": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "236c22b549d288ba400c73e13964043a66e88daeec8b31e12e91fcd239afd89e"
"computedHash": "85ff89a25110dd68ebb30b45c67b33b8f2d2bb123d407d957329a2931f0a6878"
},
"bolder": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "210d2d6c071512370613d3816ee58b256b0c5ea69f74b1363584059a8b2d3669"
"computedHash": "46e3a6a52b8bb694ca01dae4d98be4d85ab35e2ba95eee93bcb472ff6c98a70c"
},
"clarify": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "848b37fa3e6445008d9a521930a539a9cf9bac254c0419f80f2757010d4d46db"
"computedHash": "3eec88b6f38165fda2a091cdb46f78311347aa0af8d9fa40112124fdaae3bd43"
},
"colorize": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "c6ad8d7ae05771fee9232b72a16a42180e0e111c7372795f9009bf1cc52ccb95"
"computedHash": "da21ea34a9ba5aac8c87b6df23ad5b273bf60b708e5493e6bf4727fa172d2346"
},
"critique": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "8054649a24e0b92e5041c15a300026761c9dece11899dd5021fa246dff7b93c5"
"computedHash": "033e4a42923fc97741626421c0873fe25b90674076d3f6a45a9dc3a307f1918f"
},
"delight": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "629c6efc3d12ab63bdadcf6674aa2a56f12848242c619f00b8f54f0f468bf928"
"computedHash": "f46bb3c71cfe635a7742b94516ba53f0c5bfac65430513e99f1162d6d4e2e71d"
},
"distill": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "4fb24ad0ca7e75497cc25d98cf1de636a1acbbe0da919c75daab846ec63ba305"
"computedHash": "eb53dd6f18bbeb4d1b2986eaa858c9014b3c50b8ed9fcb68d841450c0b48bd12"
},
"extract": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "b01b8be227a2a094e4b65f340c5a767bdbfd27d8bf2ccee4f75c203e44a5a592"
"computedHash": "3c7ecd324b70ce07d525a2f8ecc0cda566b16612f1b413f121e82a65ccee38a2"
},
"frontend-design": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "5316f00e012e2d4a81f996adda009b6862a6684af4f6eef3f46a69f456121c6a"
"computedHash": "70c1738e2ead9b1118bbf77ce6d72f3b9a6fef91b6ba42579066350fe7d1e745"
},
"harden": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "69cecc4f28fb2f140652669bd45cc492744ad5c3c45083f5e0be5b0a5ef8acd0"
"computedHash": "54072e299abb30b20ddca38dcbb8c585ccd3dcecc414586d6279db1fccae3578"
},
"normalize": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "a2d2c28085b46b1b0a97aa4133a4df5fec9cf77a7c3cef275b901065e11bc080"
"computedHash": "82deb8f724b0188afee2bcc4f00a33b7446212ff831feda6d0b515e6d9ff0cea"
},
"onboard": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "a6d7f5c09cb2828e622fbb8fa1467bc70b041519ffed2198d9de627325d97b66"
"computedHash": "1e90eb71e79b019c50c6e4ab01d45da4c093090e26f25ee4b2250fafe5274e8a"
},
"optimize": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "278796e7d0b6febea9a53705f741f47d473cc4725684a5e0bd75fd4a99791b1c"
"computedHash": "36de9c64e36c778a01502ca9c98a7a6d54d4fa5215c62c01a2e93dcc5912d869"
},
"polish": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "72c2426ac9540884eb5032bb438324bb344d0e2dd2b80f300a9fb49fe38d624e"
"computedHash": "12a83281065df7cecc24c17fdf9a126a13f664140ed6939c8230eb3f447d1aa3"
},
"quieter": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "139b3e3c9d43b79b4e6ccffaeb874265bc916d7d90662950e9f05581d6e0c06a"
"computedHash": "bdf6069485ed66c6da4ad6932319d56c06034198d7e8467bc7cdae8d3169759e"
},
"teach-impeccable": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "61855594bf79bc5b2665cfca3580b93840cfe645872c5a2deeaa5ad336570d5a"
"computedHash": "759bfe9a53d48b87d60352db3403b62a0663e5187b2a2bd61d43657ac48d1a11"
}
}
}

View File

@@ -0,0 +1,99 @@
/**
* Agent scheduler — checks locally-stored agent configs on a periodic
* interval and triggers BE-orchestrated runs when they are due.
*
* Follows the same pattern as the daily brief scheduler in orchestrator.ts:
* a single `setInterval` tick that checks all enabled agents.
*/
import { getLocalAgents, saveLocalAgent, getDeviceId } from '../store';
import { getBackendClient } from '../api/backend-client';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** How often the scheduler checks for due agents (ms). */
const TICK_INTERVAL_MS = 60_000; // 60 seconds
/**
* Cron expression → minimum interval in ms.
* We use a simple mapping for the supported presets; unknown cron values
* are treated as manual-only.
*/
const CRON_INTERVAL_MS: Record<string, number> = {
'*/15 * * * *': 15 * 60 * 1000,
'0 * * * *': 60 * 60 * 1000,
'0 */6 * * *': 6 * 60 * 60 * 1000,
'0 0 * * *': 24 * 60 * 60 * 1000,
};
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function startAgentScheduler(): void {
if (schedulerTimer) return;
schedulerTimer = setInterval(() => {
void tickAgentScheduler();
}, TICK_INTERVAL_MS);
// Run once immediately on start
void tickAgentScheduler();
}
export function stopAgentScheduler(): void {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
}
// ---------------------------------------------------------------------------
// Tick
// ---------------------------------------------------------------------------
async function tickAgentScheduler(): Promise<void> {
const agents = getLocalAgents();
const now = Date.now();
for (const agent of agents) {
if (!agent.enabled) continue;
// Manual-only agents don't auto-trigger
const intervalMs = CRON_INTERVAL_MS[agent.scheduleCron];
if (!intervalMs) continue;
// Check if enough time has passed since lastRunAt
if (agent.lastRunAt && now - agent.lastRunAt < intervalMs) continue;
try {
const activeAgents = agents.length;
await getBackendClient().proxyPost(
'/api/v1/agents/trigger',
{
directory: agent.directory,
deviceId: getDeviceId(),
whatToExtract: agent.dataTypes,
batchInterval: agent.scheduleCron,
customAgentPrompt: agent.promptTemplate,
activeAgents,
},
);
// Mark the run time so we don't re-trigger until the next interval
saveLocalAgent({ ...agent, lastRunAt: now });
console.log(`[AgentScheduler] Triggered agent "${agent.name}" (id=${agent.id}).`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[AgentScheduler] Failed to trigger agent "${agent.name}": ${msg}`);
}
}
}

View File

@@ -1,382 +0,0 @@
/**
* Local file reader for the directory agent (Phase 3, Step 3.2).
*
* Reads files from user-configured paths, extracts text content, and returns
* structured FileData objects ready to be sent back to the backend via the
* `agent_data` WS frame.
*
* Security guarantees:
* - All paths are resolved via `fs.realpath()` before any I/O (follows
* symlinks to their real targets).
* - Every resolved file path is checked against the user's configured roots —
* symlink escapes and `..` traversals are silently rejected.
* - Extension allowlist: only files whose extension appears in the config are
* walked or read.
* - 10 MB per-file size cap: oversized files produce an error entry.
* - No shell commands — only the Node.js `fs` API.
*
* @see AI_REFACTOR_PLAN.md — Phase 3, Step 3.2
*/
import * as fs from 'fs';
import * as path from 'path';
import { PDFParse } from 'pdf-parse';
import mammoth from 'mammoth';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Files larger than this are skipped with an error entry. */
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
/**
* Content larger than this per FileData entry is split across multiple entries
* (each ≤50 KB), split on the nearest preceding newline boundary.
*/
const CHUNK_SIZE_BYTES = 50 * 1024; // 50 KB
// Extension sets — used for content-extraction dispatch
const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.eml', '.csv', '.json']);
const PDF_EXTENSIONS = new Set(['.pdf']);
const DOCX_EXTENSIONS = new Set(['.docx']);
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface AgentRunConfig {
/** Absolute directory (or file) paths the agent is allowed to read. */
paths: string[];
/**
* Allowlist of file extensions (e.g. `[".pdf", ".md"]`).
* An empty array means no files will be read.
*/
fileExtensions: string[];
}
/** Mirrors the `files[n]` element of `WsAgentData` from `api-types.ts`. */
export interface FileData {
path: string;
name: string;
content: string;
metadata?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Read all files from the agent's configured paths, filtered by extension.
*
* @returns `files` — populated FileData objects (may be chunked for large
* files), and `errors` — human-readable strings for every I/O or
* extraction failure.
*/
export async function readAgentFiles(
config: AgentRunConfig,
): Promise<{ files: FileData[]; errors: string[] }> {
const extensions = buildExtensionSet(config.fileExtensions);
if (extensions.size === 0) {
return { files: [], errors: ['No file extensions configured — nothing to read.'] };
}
// Resolve allowed root paths once (via realpath so symlinks are transparent).
const allowedRoots = await resolveRoots(config.paths);
const files: FileData[] = [];
const errors: string[] = [];
for (const configPath of config.paths) {
const normalised = path.resolve(configPath);
let realConfigPath: string;
try {
realConfigPath = await fs.promises.realpath(normalised);
} catch {
errors.push(`Path not accessible: ${configPath}`);
continue;
}
let stat: fs.Stats;
try {
stat = await fs.promises.stat(realConfigPath);
} catch (err) {
errors.push(
`Cannot stat ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
);
continue;
}
if (stat.isDirectory()) {
for await (const filePath of walkDirectory(realConfigPath, extensions, allowedRoots)) {
const results = await processFile(filePath, allowedRoots, errors);
files.push(...results);
}
} else if (stat.isFile()) {
const ext = path.extname(realConfigPath).toLowerCase();
if (extensions.has(ext)) {
const results = await processFile(realConfigPath, allowedRoots, errors);
files.push(...results);
}
}
// Anything else (devices, sockets, etc.) is silently skipped.
}
return { files, errors };
}
// ---------------------------------------------------------------------------
// Path security helpers
// ---------------------------------------------------------------------------
/**
* Resolve each configured path to its real on-disk absolute path, ignoring
* any that are unreachable.
*/
async function resolveRoots(paths: string[]): Promise<string[]> {
const resolved: string[] = [];
for (const p of paths) {
try {
const real = await fs.promises.realpath(path.resolve(p));
resolved.push(real);
} catch {
// Will be caught when we try to open the path later.
}
}
return resolved;
}
/**
* Returns `true` only if `resolvedPath` is equal to one of `allowedRoots` or
* is a strict descendant of one. Prevents directory-traversal and
* symlink-escape attacks.
*/
function isPathWithinRoots(resolvedPath: string, allowedRoots: string[]): boolean {
return allowedRoots.some(
(root) => resolvedPath === root || resolvedPath.startsWith(root + path.sep),
);
}
// ---------------------------------------------------------------------------
// Directory walker
// ---------------------------------------------------------------------------
/**
* Recursively yields absolute real paths of files matching `extensions`
* inside `dirPath`, enforcing `allowedRoots` at every step.
*/
async function* walkDirectory(
dirPath: string,
extensions: Set<string>,
allowedRoots: string[],
): AsyncGenerator<string> {
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
} catch {
return; // Unreadable directory — skip silently.
}
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
// Resolve to real path so symlinks are checked against allowed roots.
let realPath: string;
try {
realPath = await fs.promises.realpath(entryPath);
} catch {
continue; // Broken symlink or inaccessible — skip.
}
// Security guard: real path must remain within an allowed root.
if (!isPathWithinRoots(realPath, allowedRoots)) {
continue; // Symlink escape — skip silently.
}
let stat: fs.Stats;
try {
stat = await fs.promises.stat(realPath); // Always follows symlinks.
} catch {
continue;
}
if (stat.isDirectory()) {
yield* walkDirectory(realPath, extensions, allowedRoots);
} else if (stat.isFile()) {
if (extensions.has(path.extname(entry.name).toLowerCase())) {
yield realPath;
}
}
}
}
// ---------------------------------------------------------------------------
// Per-file processing
// ---------------------------------------------------------------------------
/**
* Resolve, safety-check, size-check, extract, and (if needed) chunk a single
* file. Any non-fatal problem is recorded in `errors`; on failure the
* returned array is empty.
*/
async function processFile(
filePath: string,
allowedRoots: string[],
errors: string[],
): Promise<FileData[]> {
// Re-resolve defensively (callers may pass already-real paths, but let's
// be certain regardless).
let realPath: string;
try {
realPath = await fs.promises.realpath(filePath);
} catch (err) {
errors.push(
`Cannot resolve path ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
if (!isPathWithinRoots(realPath, allowedRoots)) {
errors.push(`Path outside allowed roots, rejected: ${filePath}`);
return [];
}
let stat: fs.Stats;
try {
stat = await fs.promises.stat(realPath);
} catch (err) {
errors.push(
`Cannot stat ${realPath}: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
if (stat.size > MAX_FILE_SIZE_BYTES) {
errors.push(
`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB), skipping: ${realPath}`,
);
return [];
}
const ext = path.extname(realPath).toLowerCase();
const baseMetadata: Record<string, unknown> = {
size: stat.size,
mtime: stat.mtimeMs,
extension: ext,
};
let content: string;
try {
content = await extractContent(realPath, ext);
} catch (err) {
errors.push(
`Failed to extract content from ${realPath}: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
const name = path.basename(realPath);
const contentBytes = Buffer.byteLength(content, 'utf8');
if (contentBytes <= CHUNK_SIZE_BYTES) {
return [{ path: realPath, name, content, metadata: baseMetadata }];
}
// Large content: split into ≤50 KB chunks on newline boundaries.
const chunks = chunkContent(content, CHUNK_SIZE_BYTES);
return chunks.map((chunk, idx) => ({
path: realPath,
name,
content: chunk,
metadata: { ...baseMetadata, chunk: idx, totalChunks: chunks.length },
}));
}
// ---------------------------------------------------------------------------
// Content extraction by file type
// ---------------------------------------------------------------------------
async function extractContent(filePath: string, ext: string): Promise<string> {
if (TEXT_EXTENSIONS.has(ext)) {
return fs.promises.readFile(filePath, 'utf8');
}
if (PDF_EXTENSIONS.has(ext)) {
const buffer = await fs.promises.readFile(filePath);
const parser = new PDFParse({ data: new Uint8Array(buffer) });
const result = await parser.getText();
return result.text;
}
if (DOCX_EXTENSIONS.has(ext)) {
const result = await mammoth.extractRawText({ path: filePath });
return result.value;
}
throw new Error(`Unsupported file extension: ${ext}`);
}
// ---------------------------------------------------------------------------
// Chunking
// ---------------------------------------------------------------------------
/**
* Splits `content` into chunks where each chunk's UTF-8 byte length is at
* most `maxBytes`. Chunk boundaries are snapped back to the last newline
* within the byte limit (if one exists), so chunks tend to end on complete
* lines.
*/
export function chunkContent(content: string, maxBytes: number = CHUNK_SIZE_BYTES): string[] {
const chunks: string[] = [];
let start = 0;
while (start < content.length) {
let byteCount = 0;
let end = start;
// Advance `end` one code point at a time until we exceed `maxBytes`.
while (end < content.length) {
const code = content.charCodeAt(end);
const charBytes =
code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0xd800 || code > 0xdfff ? 3 : 2;
// Surrogate pair: the paired low surrogate will be counted separately.
if (byteCount + charBytes > maxBytes) break;
byteCount += charBytes;
end++;
}
// Edge case: a single character exceeds the limit (shouldn't happen for
// normal UTF-8 text, but guard against infinite loops).
if (end === start) {
end = start + 1;
}
// Snap back to the last newline so chunks end on complete lines.
if (end < content.length) {
const nlIdx = content.lastIndexOf('\n', end - 1);
if (nlIdx > start) {
end = nlIdx + 1; // Include the newline character in this chunk.
}
}
chunks.push(content.slice(start, end));
start = end;
}
return chunks;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildExtensionSet(fileExtensions: string[]): Set<string> {
return new Set(
fileExtensions.map((ext) => {
const lower = ext.toLowerCase();
return lower.startsWith('.') ? lower : `.${lower}`;
}),
);
}

View File

@@ -225,7 +225,7 @@ export async function generateAndCacheBrief(): Promise<boolean> {
let content = '';
try {
const client = getBackendClient();
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, undefined, {
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, undefined, undefined, {
onStart: NOOP,
onText: (chunk) => { content += chunk; },
onEnd: NOOP,
@@ -290,7 +290,7 @@ export async function dailyBrief(sender?: Electron.WebContents, requestId?: stri
try {
const client = getBackendClient();
const activeRequestId = requestId ?? crypto.randomUUID();
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, activeRequestId, {
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, activeRequestId, undefined, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
onText: (chunk) => {
content += chunk;

View File

@@ -18,7 +18,7 @@
*/
import WebSocket from 'ws';
import { getStore, getDeviceId } from '../store';
import { getStore, getDeviceId, getLocalAgents } from '../store';
import { getAuthManager } from '../auth/auth-manager';
import { toSnakeCase, toCamelCase } from '../../shared/casing';
import {
@@ -26,14 +26,10 @@ import {
} from '../../shared/api-types';
import type {
WsToolResult,
WsAgentRun,
WsAgentData,
LocalAgentConfig,
WsFloatingRequest,
WsFloatingDomain,
} from '../../shared/api-types';
import { DrizzleExecutor } from './drizzle-executor';
import { readAgentFiles } from '../agents/file-reader';
// ---------------------------------------------------------------------------
// Dev-mode logger
@@ -130,6 +126,12 @@ interface StreamListener {
reject: (err: Error) => void;
}
/** Pending journey reply listener — resolves when a `journey_reply` arrives. */
interface JourneyListener {
resolve: (reply: { sessionId: string; message: string; done: boolean; promptTemplate?: string | null }) => void;
reject: (err: Error) => void;
}
// ---------------------------------------------------------------------------
// BackendClient
// ---------------------------------------------------------------------------
@@ -153,6 +155,9 @@ export class BackendClient {
/** V3 stream listeners keyed by requestId. */
private streamListeners: Map<string, StreamListener> = new Map();
/** Journey reply listeners keyed by sessionId. */
private journeyListeners: Map<string, JourneyListener> = new Map();
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
@@ -319,6 +324,74 @@ export class BackendClient {
return { requestId: activeRequestId, promise };
}
// -------------------------------------------------------------------------
// Journey — setup agent prompt via WS
// -------------------------------------------------------------------------
/**
* Start a journey session for custom prompt creation.
* Sends `journey_start` over the persistent WS and awaits the first
* `journey_reply` frame from the server.
*/
sendJourneyStart(
sessionId: string,
agentType: 'local_directory' | 'gmail' | 'teams' | 'outlook',
dataTypes: string[],
directory?: string,
existingTemplate?: string | null,
): Promise<{ sessionId: string; message: string; done: boolean; promptTemplate?: string | null }> {
return new Promise((resolve, reject) => {
this.journeyListeners.set(sessionId, { resolve, reject });
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.journeyListeners.delete(sessionId);
reject(new OfflineError('Persistent WS not connected'));
return;
}
const payload = toSnakeCase({
type: 'journey_start',
sessionId,
agentType,
directory: directory ?? null,
dataTypes,
existingTemplate: existingTemplate ?? null,
});
logWsSend(payload);
ws.send(JSON.stringify(payload));
});
}
/**
* Send a user message in an active journey session.
* Sends `journey_message` over the persistent WS and awaits the next
* `journey_reply` frame.
*/
sendJourneyMessage(
sessionId: string,
message: string,
): Promise<{ sessionId: string; message: string; done: boolean; promptTemplate?: string | null }> {
return new Promise((resolve, reject) => {
this.journeyListeners.set(sessionId, { resolve, reject });
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.journeyListeners.delete(sessionId);
reject(new OfflineError('Persistent WS not connected'));
return;
}
const payload = toSnakeCase({
type: 'journey_message',
sessionId,
message,
});
logWsSend(payload);
ws.send(JSON.stringify(payload));
});
}
// -------------------------------------------------------------------------
// HTTP utilities
// -------------------------------------------------------------------------
@@ -537,7 +610,7 @@ export class BackendClient {
* Opens and maintains a persistent WebSocket connection to the backend
* device endpoint (`/api/v1/ws/device`). On connect, sends a
* `device_hello` frame identifying this machine and its active local
* agents. Handles `agent_run` and `tool_call` frames; auto-reconnects
* agents. Handles `tool_call` frames; auto-reconnects
* with exponential backoff; sends WS-level heartbeat pings every 30 s.
*
* Safe to call multiple times — a no-op if already connected or
@@ -602,17 +675,11 @@ export class BackendClient {
try { this.onConnectedCallback(); } catch { /* ignore */ }
}
// Fetch enabled local agent IDs bound to this device
// Read enabled local agent IDs from local storage
const deviceId = getDeviceId();
let agentIds: string[] = [];
try {
const agents = await this.proxyGet<LocalAgentConfig[]>('/api/v1/agents/local');
agentIds = agents
.filter((a) => a.enabled && a.deviceId === deviceId)
.map((a) => a.id);
} catch {
// Non-fatal — send hello with empty list
}
const agentIds = getLocalAgents()
.filter((a) => a.enabled)
.map((a) => a.id);
ws.send(JSON.stringify(toSnakeCase({ type: 'device_hello', deviceId, agentIds })));
console.log(`[DeviceWS] Sent device_hello (deviceId=${deviceId}, agents=${agentIds.length}).`);
@@ -636,10 +703,6 @@ export class BackendClient {
this.clearPongTimer();
switch (frame.data.type) {
case 'agent_run':
void this.handleAgentRunAndSend(frame.data, ws);
break;
case 'tool_call': {
const toolCall = frame.data;
void (async () => {
@@ -687,6 +750,20 @@ export class BackendClient {
listener?.onDomain(frame.data.domain);
break;
}
case 'journey_reply': {
const jl = this.journeyListeners.get(frame.data.sessionId);
if (jl) {
this.journeyListeners.delete(frame.data.sessionId);
jl.resolve({
sessionId: frame.data.sessionId,
message: frame.data.message,
done: frame.data.done,
promptTemplate: frame.data.promptTemplate,
});
}
break;
}
}
});
@@ -708,13 +785,19 @@ export class BackendClient {
console.log(`[DeviceWS] Connection closed (code ${code}).`);
// Reject any in-flight stream listeners so callers don't hang forever.
const closeErr = new Error('WebSocket connection closed');
if (this.streamListeners.size > 0) {
const err = new Error('WebSocket connection closed');
for (const listener of this.streamListeners.values()) {
try { listener.onError(err); } catch { /* ignore */ }
try { listener.onError(closeErr); } catch { /* ignore */ }
}
this.streamListeners.clear();
}
if (this.journeyListeners.size > 0) {
for (const jl of this.journeyListeners.values()) {
try { jl.reject(closeErr); } catch { /* ignore */ }
}
this.journeyListeners.clear();
}
if (this.shouldReconnect) {
this.scheduleReconnect();
@@ -722,60 +805,6 @@ export class BackendClient {
});
}
/**
* Reads files for an `agent_run` frame and transmits the resulting
* `agent_data` and `agent_complete` frames back over the persistent WS.
*
* Validates device ID first — if the frame targets a different device,
* responds immediately with an error `agent_complete` frame and returns.
*/
private async handleAgentRunAndSend(frame: WsAgentRun, ws: WebSocket): Promise<void> {
const localDeviceId = getDeviceId();
// Device-binding check (Step 3.3, final checkbox): if the backend
// includes a target device ID in config, verify it matches this machine.
const targetDeviceId = (frame.config as unknown as { deviceId?: string }).deviceId;
if (targetDeviceId && targetDeviceId !== localDeviceId) {
console.warn(
`[DeviceWS] agent_run for deviceId=${targetDeviceId} ignored` +
` (this device=${localDeviceId}).`,
);
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify(
toSnakeCase({
type: 'agent_complete',
runId: frame.runId,
filesRead: 0,
errors: [`Device mismatch: expected ${targetDeviceId}, got ${localDeviceId}`],
}),
),
);
}
return;
}
const { files, errors, filesRead } = await this.handleAgentRun(frame);
if (!ws.readyState || ws.readyState !== WebSocket.OPEN) return;
// Send agent_data with all files (single frame — chunking deferred)
if (files.length > 0) {
ws.send(JSON.stringify(toSnakeCase({ type: 'agent_data', runId: frame.runId, files })));
}
// Send agent_complete
ws.send(
JSON.stringify(
toSnakeCase({ type: 'agent_complete', runId: frame.runId, filesRead, errors }),
),
);
console.log(
`[DeviceWS] agent_complete sent` +
` (runId=${frame.runId}, filesRead=${filesRead}, errors=${errors.length}).`,
);
}
private startHeartbeat(ws: WebSocket): void {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
@@ -828,34 +857,6 @@ export class BackendClient {
}
}
// -------------------------------------------------------------------------
// Agent handler (Phase 3)
// -------------------------------------------------------------------------
/**
* Read files for an `agent_run` frame and return structured file data.
* Frame transmission is handled by `handleAgentRunAndSend()`.
*/
async handleAgentRun(
frame: WsAgentRun,
): Promise<{ files: WsAgentData['files']; errors: string[]; filesRead: number }> {
console.log(
`[Agent] Run requested: agentId=${frame.agentId} runId=${frame.runId}` +
` paths=${frame.config.paths.join(', ')}`,
);
const { files, errors } = await readAgentFiles({
paths: frame.config.paths,
fileExtensions: frame.config.fileExtensions,
});
if (errors.length > 0) {
console.warn(`[Agent] runId=${frame.runId} completed with ${errors.length} error(s):`, errors);
}
return { files, errors, filesRead: files.length };
}
// -------------------------------------------------------------------------
// Retry
// -------------------------------------------------------------------------

View File

@@ -11,6 +11,8 @@
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.4
*/
import * as fs from 'fs';
import * as path from 'path';
import { eq, and, or, like, isNull, asc, desc, gte, lte, SQL } from 'drizzle-orm';
import { getDb } from '../db';
import { tasks, projects, clients, timelineEvents, notes, taskComments } from '../db/schema';
@@ -35,6 +37,13 @@ const TABLE_REGISTRY = {
type TableName = keyof typeof TABLE_REGISTRY;
type AnyTable = (typeof TABLE_REGISTRY)[TableName];
// ---------------------------------------------------------------------------
// Filesystem constants
// ---------------------------------------------------------------------------
/** Maximum file content size returned by read_file_content (500 KB). */
const MAX_READ_SIZE_BYTES = 500 * 1024;
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
@@ -164,6 +173,12 @@ export class DrizzleExecutor {
return this.handleVectorUpsert(payload);
case 'vector_search':
return this.handleVectorSearch(payload);
case 'list_directory':
return this.handleListDirectory(payload);
case 'read_file_content':
return this.handleReadFileContent(payload);
case 'get_file_metadata':
return this.handleGetFileMetadata(payload);
default:
throw new ExecutorError(`Unknown action: "${action as string}"`);
}
@@ -294,4 +309,96 @@ export class DrizzleExecutor {
return { results };
}
// -------------------------------------------------------------------------
// Filesystem handlers
// -------------------------------------------------------------------------
private async handleListDirectory(payload: WsToolCall): Promise<Record<string, unknown>> {
const data = (payload.data ?? {}) as Record<string, unknown>;
const dirPath = data['path'] as string | undefined;
if (!dirPath) throw new ExecutorError('"data.path" is required for list_directory');
const resolved = await fs.promises.realpath(path.resolve(dirPath));
let dirents: fs.Dirent[];
try {
dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
} catch (err) {
throw new ExecutorError(
`Cannot read directory ${dirPath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
const entries = dirents.map((d) => ({
name: d.name,
type: d.isDirectory() ? 'directory' : 'file',
path: path.join(resolved, d.name),
}));
return { entries };
}
private async handleReadFileContent(payload: WsToolCall): Promise<Record<string, unknown>> {
const data = (payload.data ?? {}) as Record<string, unknown>;
const filePath = data['path'] as string | undefined;
if (!filePath) throw new ExecutorError('"data.path" is required for read_file_content');
const resolved = await fs.promises.realpath(path.resolve(filePath));
let stat: fs.Stats;
try {
stat = await fs.promises.stat(resolved);
} catch (err) {
throw new ExecutorError(
`Cannot stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (!stat.isFile()) {
throw new ExecutorError(`Not a file: ${filePath}`);
}
let content: string;
if (stat.size > MAX_READ_SIZE_BYTES) {
// Read only the first MAX_READ_SIZE_BYTES to prevent context saturation
const buf = Buffer.alloc(MAX_READ_SIZE_BYTES);
const fd = await fs.promises.open(resolved, 'r');
try {
await fd.read(buf, 0, MAX_READ_SIZE_BYTES, 0);
} finally {
await fd.close();
}
content = buf.toString('utf8') + '\n[…truncated]';
} else {
content = await fs.promises.readFile(resolved, 'utf8');
}
return { content };
}
private async handleGetFileMetadata(payload: WsToolCall): Promise<Record<string, unknown>> {
const data = (payload.data ?? {}) as Record<string, unknown>;
const filePath = data['path'] as string | undefined;
if (!filePath) throw new ExecutorError('"data.path" is required for get_file_metadata');
const resolved = await fs.promises.realpath(path.resolve(filePath));
let stat: fs.Stats;
try {
stat = await fs.promises.stat(resolved);
} catch (err) {
throw new ExecutorError(
`Cannot stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
return {
name: path.basename(resolved),
extension: path.extname(resolved).toLowerCase(),
size: stat.size,
createdAt: stat.birthtime.toISOString(),
modifiedAt: stat.mtime.toISOString(),
};
}
}

View File

@@ -11,6 +11,7 @@ import { getBackupManager } from './backup/backup-manager';
import { getSyncQueue } from './backup/sync-queue';
import { getStore } from './store';
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
@@ -90,11 +91,13 @@ app.on('ready', () => {
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
startBriefScheduler();
startAgentScheduler();
});
// Clean up the persistent WS and backup timers before the app exits
app.on('will-quit', () => {
stopBriefScheduler();
stopAgentScheduler();
getBackupManager().stopPeriodicBackup();
getBackendClient().disconnectPersistent();
});

View File

@@ -4,9 +4,10 @@ import { eq, asc, inArray, and, or, like, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/sqlite-core';
import { getDb } from '../db';
import { clients, projects, tasks, timelineEvents, notes, taskComments } from '../db/schema';
import { getStore, getDeviceId } from '../store';
import { getStore, getDeviceId, getLocalAgents, getLocalAgent, saveLocalAgent, deleteLocalAgent } from '../store';
import type { LocalAgentLocalConfig } from '../store';
import { getBackendClient } from '../api/backend-client';
import type { AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog, JourneyMessage, BackupMetadata } from '../../shared/api-types';
import type { AgentCatalogItem, CloudAgentConfig, AgentRunLog, BackupMetadata } from '../../shared/api-types';
import { orchestrate, orchestrateFloating, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
import { upsertNoteEmbedding } from '../db/vectordb';
import { getAuthManager, AuthError } from '../auth/auth-manager';
@@ -705,73 +706,66 @@ const aiRouter = router({
// ---------------------------------------------------------------------------
const agentLocalRouter = router({
list: publicProcedure.query(async () => {
try {
return await getBackendClient().proxyGet<LocalAgentConfig[]>('/api/v1/agents/local');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to list local agents';
console.error('[Agent] local.list error:', msg);
return [];
}
list: publicProcedure.query(() => {
return getLocalAgents();
}),
create: publicProcedure
.input(z.object({
name: z.string(),
directoryPaths: z.array(z.string()),
directory: z.string(),
dataTypes: z.array(z.string()),
fileExtensions: z.array(z.string()),
promptTemplate: z.string(),
scheduleCron: z.string(),
}))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<LocalAgentConfig>(
'/api/v1/agents/local',
{ ...input, deviceId: getDeviceId() },
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create local agent';
return { data: null, error: msg };
}
.mutation(({ input }) => {
const agent: LocalAgentLocalConfig = {
id: crypto.randomUUID(),
name: input.name,
directory: input.directory,
dataTypes: input.dataTypes,
promptTemplate: input.promptTemplate,
scheduleCron: input.scheduleCron,
enabled: true,
lastRunAt: null,
};
saveLocalAgent(agent);
return { data: agent, error: null };
}),
update: publicProcedure
.input(z.object({
id: z.string(),
name: z.string().optional(),
directoryPaths: z.array(z.string()).optional(),
directory: z.string().optional(),
dataTypes: z.array(z.string()).optional(),
fileExtensions: z.array(z.string()).optional(),
promptTemplate: z.string().optional(),
scheduleCron: z.string().optional(),
enabled: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...updates } = input;
try {
const result = await getBackendClient().proxyPut<LocalAgentConfig>(
`/api/v1/agents/local/${id}`,
updates as Record<string, unknown>,
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update local agent';
return { data: null, error: msg };
.mutation(({ input }) => {
const existing = getLocalAgent(input.id);
if (!existing) {
return { data: null, error: 'Agent not found' };
}
const updated: LocalAgentLocalConfig = {
...existing,
...(input.name !== undefined && { name: input.name }),
...(input.directory !== undefined && { directory: input.directory }),
...(input.dataTypes !== undefined && { dataTypes: input.dataTypes }),
...(input.promptTemplate !== undefined && { promptTemplate: input.promptTemplate }),
...(input.scheduleCron !== undefined && { scheduleCron: input.scheduleCron }),
...(input.enabled !== undefined && { enabled: input.enabled }),
};
saveLocalAgent(updated);
return { data: updated, error: null };
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
try {
await getBackendClient().proxyDelete<{ ok: boolean }>(`/api/v1/agents/local/${input.id}`);
return { success: true as const, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to delete local agent';
return { success: false as const, error: msg };
}
.mutation(({ input }) => {
deleteLocalAgent(input.id);
return { success: true as const, error: null };
}),
});
@@ -848,16 +842,22 @@ const agentCloudRouter = router({
const agentJourneyRouter = router({
start: publicProcedure
.input(z.object({
agentType: z.enum(['local', 'cloud']),
agentId: z.string().optional(),
agentType: z.enum(['local_directory', 'gmail', 'teams', 'outlook']),
dataTypes: z.array(z.string()),
directory: z.string().optional(),
existingTemplate: z.string().optional(),
}))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<JourneyMessage>(
'/api/v1/agents/journey/start',
input as Record<string, unknown>,
const sessionId = crypto.randomUUID();
const result = await getBackendClient().sendJourneyStart(
sessionId,
input.agentType,
input.dataTypes,
input.directory,
input.existingTemplate,
);
return { data: result, error: null };
return { data: { sessionId: result.sessionId, message: result.message, done: result.done, promptTemplate: result.promptTemplate ?? undefined }, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to start journey';
return { data: null, error: msg };
@@ -868,11 +868,8 @@ const agentJourneyRouter = router({
.input(z.object({ sessionId: z.string(), message: z.string() }))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<JourneyMessage>(
'/api/v1/agents/journey/message',
input as Record<string, unknown>,
);
return { data: result, error: null };
const result = await getBackendClient().sendJourneyMessage(input.sessionId, input.message);
return { data: { sessionId: result.sessionId, message: result.message, done: result.done, promptTemplate: result.promptTemplate ?? undefined }, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to send journey message';
return { data: null, error: msg };
@@ -917,14 +914,39 @@ const agentRouter = router({
}
}),
/** Manually trigger an agent run. */
/** Check whether the user's plan allows creating a new agent. */
canCreate: publicProcedure.mutation(async () => {
try {
const activeAgents = getLocalAgents().length;
const result = await getBackendClient().proxyPost<{ allowed: boolean; tier: string; activeAgents: number; limit: number }>(
'/api/v1/agents/can-create',
{ activeAgents },
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to check agent quota';
return { data: null, error: msg };
}
}),
/** Manually trigger a local agent run via the BE two-phase runner. */
runNow: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<{ ok: boolean; runId: string }>(
`/api/v1/agents/${input.id}/run`,
{},
const agent = getLocalAgent(input.id);
if (!agent) return { data: null, error: 'Agent not found' };
const activeAgents = getLocalAgents().length;
const result = await getBackendClient().proxyPost<{ id: string; agentId: string; agentType: string; status: string; itemsProcessed: number; itemsCreated: number; errors: string[]; startedAt: number; completedAt: number | null }>(
'/api/v1/agents/trigger',
{
directory: agent.directory,
deviceId: getDeviceId(),
whatToExtract: agent.dataTypes,
batchInterval: agent.scheduleCron,
customAgentPrompt: agent.promptTemplate,
activeAgents,
},
);
return { data: result, error: null };
} catch (err) {

View File

@@ -1,5 +1,24 @@
import Store from 'electron-store';
// ---------------------------------------------------------------------------
// Local agent config — stored entirely on the FE, never on the backend.
// ---------------------------------------------------------------------------
export interface LocalAgentLocalConfig {
id: string;
name: string;
directory: string;
dataTypes: string[];
promptTemplate: string;
scheduleCron: string;
enabled: boolean;
lastRunAt: number | null;
}
// ---------------------------------------------------------------------------
// App settings (electron-store shape)
// ---------------------------------------------------------------------------
interface AppSettings {
sidebarCollapsed: boolean;
encryptedTokens: Record<string, string>;
@@ -8,7 +27,7 @@ interface AppSettings {
/**
* Stable device identifier — UUID v4 generated once on first launch and
* persisted forever. Used to bind local agents to the machine they were
* configured on (Step 3.3).
* configured on.
*/
deviceId: string;
/** Whether automatic periodic backup is enabled. */
@@ -19,6 +38,8 @@ interface AppSettings {
lastBackupAt: number | null;
/** Cached daily brief — regenerated once per day or when relevant data changes. */
dailyBriefCache: { content: string; date: string } | null;
/** Locally-managed agent configurations. */
localAgents: LocalAgentLocalConfig[];
}
let _store: Store<AppSettings> | null = null;
@@ -35,6 +56,7 @@ export function getStore(): Store<AppSettings> {
backupIntervalHours: 24,
lastBackupAt: null,
dailyBriefCache: null,
localAgents: [],
},
});
}
@@ -54,3 +76,31 @@ export function getDeviceId(): string {
}
return id;
}
// ---------------------------------------------------------------------------
// Local agent helpers
// ---------------------------------------------------------------------------
export function getLocalAgents(): LocalAgentLocalConfig[] {
return getStore().get('localAgents');
}
export function getLocalAgent(id: string): LocalAgentLocalConfig | undefined {
return getLocalAgents().find((a) => a.id === id);
}
export function saveLocalAgent(agent: LocalAgentLocalConfig): void {
const agents = getLocalAgents();
const idx = agents.findIndex((a) => a.id === agent.id);
if (idx >= 0) {
agents[idx] = agent;
} else {
agents.push(agent);
}
getStore().set('localAgents', agents);
}
export function deleteLocalAgent(id: string): void {
const agents = getLocalAgents().filter((a) => a.id !== id);
getStore().set('localAgents', agents);
}

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,9 @@ export const ToolCallActionSchema = z.enum([
'delete',
'vector_upsert',
'vector_search',
'list_directory',
'read_file_content',
'get_file_metadata',
]);
export type ToolCallAction = z.infer<typeof ToolCallActionSchema>;
@@ -64,32 +67,6 @@ export const WsToolResultSchema = z.object({
});
export type WsToolResult = z.infer<typeof WsToolResultSchema>;
// --- Agent frames (Phase 3) — Client → Server --------------------------------
/** Sent by Electron with pre-processed file contents for an agent run. */
export const WsAgentDataSchema = z.object({
type: z.literal('agent_data'),
runId: z.string(),
files: z.array(
z.object({
path: z.string(),
name: z.string(),
content: z.string(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
),
});
export type WsAgentData = z.infer<typeof WsAgentDataSchema>;
/** Sent by Electron when it has finished reading all files for a run. */
export const WsAgentCompleteSchema = z.object({
type: z.literal('agent_complete'),
runId: z.string(),
filesRead: z.number().int(),
errors: z.array(z.string()),
});
export type WsAgentComplete = z.infer<typeof WsAgentCompleteSchema>;
/**
* First frame sent by Electron on the persistent device WS connection.
* Identifies the device and the agent configs it owns.
@@ -122,13 +99,34 @@ export const WsFloatingRequestSchema = z.object({
});
export type WsFloatingRequest = z.infer<typeof WsFloatingRequestSchema>;
// --- Journey frames — Client → Server ----------------------------------------
/** Start a setup journey for custom prompt creation. */
export const WsJourneyStartSchema = z.object({
type: z.literal('journey_start'),
sessionId: z.string(),
agentType: z.enum(['local_directory', 'gmail', 'teams', 'outlook']),
directory: z.string().optional(),
dataTypes: z.array(z.string()),
existingTemplate: z.string().nullable().optional(),
});
export type WsJourneyStart = z.infer<typeof WsJourneyStartSchema>;
/** Send a user reply during a journey conversation. */
export const WsJourneyMessageSchema = z.object({
type: z.literal('journey_message'),
sessionId: z.string(),
message: z.string(),
});
export type WsJourneyMessage = z.infer<typeof WsJourneyMessageSchema>;
export const WsClientFrameSchema = z.discriminatedUnion('type', [
WsToolResultSchema,
WsAgentDataSchema,
WsAgentCompleteSchema,
WsDeviceHelloSchema,
WsHomeRequestSchema,
WsFloatingRequestSchema,
WsJourneyStartSchema,
WsJourneyMessageSchema,
]);
export type WsClientFrame = z.infer<typeof WsClientFrameSchema>;
@@ -151,26 +149,6 @@ export const WsPingSchema = z.object({
});
export type WsPing = z.infer<typeof WsPingSchema>;
// --- Agent frames (Phase 3) — Server → Client --------------------------------
/**
* Sent by the backend to trigger a local directory agent run on the Electron
* client. Electron should read files matching the config and respond with
* `agent_data` frames followed by an `agent_complete` frame.
*/
export const WsAgentRunSchema = z.object({
type: z.literal('agent_run'),
runId: z.string(),
agentId: z.string(),
config: z.object({
paths: z.array(z.string()),
fileExtensions: z.array(z.string()),
promptTemplate: z.string(),
dataTypes: z.array(z.string()),
}),
});
export type WsAgentRun = z.infer<typeof WsAgentRunSchema>;
// --- V3 Chat frames — Server → Client ----------------------------------------
export const WsStreamStartSchema = z.object({
@@ -210,6 +188,18 @@ export const WsFloatingDomainSchema = z.object({
});
export type WsFloatingDomain = z.infer<typeof WsFloatingDomainSchema>;
// --- Journey frames — Server → Client ----------------------------------------
/** Server reply during a setup journey conversation. */
export const WsJourneyReplySchema = z.object({
type: z.literal('journey_reply'),
sessionId: z.string(),
message: z.string(),
done: z.boolean(),
promptTemplate: z.string().nullable().optional(),
});
export type WsJourneyReply = z.infer<typeof WsJourneyReplySchema>;
// --- V3 Block data interfaces ------------------------------------------------
export interface ChartBlockData {
@@ -247,11 +237,11 @@ export interface TimelineBlockData {
export const WsServerFrameSchema = z.discriminatedUnion('type', [
WsToolCallSchema,
WsPingSchema,
WsAgentRunSchema,
WsStreamStartSchema,
WsStreamTextSchema,
WsStreamEndSchema,
WsFloatingDomainSchema,
WsJourneyReplySchema,
]);
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;
@@ -283,12 +273,11 @@ export type PermissionGrant = z.infer<typeof PermissionGrantSchema>;
export const AgentCatalogItemSchema = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['local', 'cloud']),
type: z.enum(['local_directory', 'gmail', 'teams', 'outlook']),
description: z.string(),
/** Cloud provider identifier (e.g. 'gmail', 'teams', 'outlook'). */
provider: z.string().optional(),
supportedDataTypes: z.array(z.string()),
defaultFileExtensions: z.array(z.string()).optional(),
});
export type AgentCatalogItem = z.infer<typeof AgentCatalogItemSchema>;
@@ -300,7 +289,6 @@ export const LocalAgentConfigSchema = z.object({
name: z.string(),
directoryPaths: z.array(z.string()),
dataTypes: z.array(z.string()),
fileExtensions: z.array(z.string()),
promptTemplate: z.string(),
scheduleCron: z.string(),
enabled: z.boolean(),