Compare commits
3 Commits
b7ddc95171
...
6a87590176
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a87590176 | |||
| cd4644637b | |||
| 9fd441e7d7 |
55
package-lock.json
generated
55
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
99
src/main/agents/agent-scheduler.ts
Normal file
99
src/main/agents/agent-scheduler.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user