mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 22:02:39 +02:00
feat(filesystem): enhance agent filesystem API with searchSpaceId support for improved context handling
This commit is contained in:
parent
86e2dc8a5d
commit
27e16231c1
10 changed files with 349 additions and 85 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { app, dialog } from "electron";
|
||||
import { access, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
export type AgentFilesystemMode = "cloud" | "desktop_local_folder";
|
||||
|
||||
|
|
@ -10,9 +11,15 @@ export interface AgentFilesystemSettings {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
type AgentFilesystemSettingsStore = {
|
||||
version: 2;
|
||||
spaces: Record<string, AgentFilesystemSettings>;
|
||||
};
|
||||
|
||||
const SETTINGS_FILENAME = "agent-filesystem-settings.json";
|
||||
const MAX_LOCAL_ROOTS = 10;
|
||||
let cachedSettings: AgentFilesystemSettings | null = null;
|
||||
const DEFAULT_SPACE_KEY = "default";
|
||||
let cachedSettingsStore: AgentFilesystemSettingsStore | null = null;
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return join(app.getPath("userData"), SETTINGS_FILENAME);
|
||||
|
|
@ -67,37 +74,97 @@ async function normalizeLocalRootPathsCanonical(paths: unknown): Promise<string[
|
|||
return [...uniquePaths];
|
||||
}
|
||||
|
||||
export async function getAgentFilesystemSettings(): Promise<AgentFilesystemSettings> {
|
||||
if (cachedSettings) {
|
||||
return cachedSettings;
|
||||
function normalizeSearchSpaceKey(searchSpaceId?: number | null): string {
|
||||
if (typeof searchSpaceId === "number" && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
|
||||
return String(searchSpaceId);
|
||||
}
|
||||
return DEFAULT_SPACE_KEY;
|
||||
}
|
||||
|
||||
function toSettingsFromUnknown(value: unknown): AgentFilesystemSettings | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const parsed = value as Partial<AgentFilesystemSettings>;
|
||||
if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mode: parsed.mode,
|
||||
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
|
||||
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultStore(): AgentFilesystemSettingsStore {
|
||||
return { version: 2, spaces: {} };
|
||||
}
|
||||
|
||||
function getSettingsFromStore(
|
||||
store: AgentFilesystemSettingsStore,
|
||||
searchSpaceId?: number | null
|
||||
): AgentFilesystemSettings {
|
||||
const key = normalizeSearchSpaceKey(searchSpaceId);
|
||||
return store.spaces[key] ?? getDefaultSettings();
|
||||
}
|
||||
|
||||
async function loadAgentFilesystemSettingsStore(): Promise<AgentFilesystemSettingsStore> {
|
||||
if (cachedSettingsStore) {
|
||||
return cachedSettingsStore;
|
||||
}
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
const raw = await readFile(getSettingsPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<AgentFilesystemSettings>;
|
||||
if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") {
|
||||
cachedSettings = getDefaultSettings();
|
||||
return cachedSettings;
|
||||
const raw = await readFile(settingsPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const nextStore = getDefaultStore();
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"version" in parsed &&
|
||||
"spaces" in parsed &&
|
||||
(parsed as { version?: unknown }).version === 2
|
||||
) {
|
||||
const parsedStore = parsed as { spaces?: Record<string, unknown>; version: 2 };
|
||||
if (parsedStore.spaces && typeof parsedStore.spaces === "object") {
|
||||
for (const [spaceKey, rawSettings] of Object.entries(parsedStore.spaces)) {
|
||||
const normalizedSettings = toSettingsFromUnknown(rawSettings);
|
||||
if (normalizedSettings) {
|
||||
nextStore.spaces[String(spaceKey)] = normalizedSettings;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Strict migration: reject legacy/non-scoped settings and reset.
|
||||
await mkdir(dirname(settingsPath), { recursive: true });
|
||||
await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8");
|
||||
}
|
||||
cachedSettings = {
|
||||
mode: parsed.mode,
|
||||
// Avoid filesystem I/O during reads; canonicalize paths on write.
|
||||
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
|
||||
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
return cachedSettings;
|
||||
cachedSettingsStore = nextStore;
|
||||
return nextStore;
|
||||
} catch {
|
||||
cachedSettings = getDefaultSettings();
|
||||
return cachedSettings;
|
||||
cachedSettingsStore = getDefaultStore();
|
||||
await mkdir(dirname(settingsPath), { recursive: true });
|
||||
await writeFile(settingsPath, JSON.stringify(cachedSettingsStore, null, 2), "utf8");
|
||||
return cachedSettingsStore;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentFilesystemSettings(
|
||||
searchSpaceId?: number | null
|
||||
): Promise<AgentFilesystemSettings> {
|
||||
const store = await loadAgentFilesystemSettingsStore();
|
||||
return getSettingsFromStore(store, searchSpaceId);
|
||||
}
|
||||
|
||||
export async function setAgentFilesystemSettings(
|
||||
searchSpaceId: number | null | undefined,
|
||||
settings: {
|
||||
mode?: AgentFilesystemMode;
|
||||
localRootPaths?: string[] | null;
|
||||
}
|
||||
): Promise<AgentFilesystemSettings> {
|
||||
const current = await getAgentFilesystemSettings();
|
||||
const store = await loadAgentFilesystemSettingsStore();
|
||||
const key = normalizeSearchSpaceKey(searchSpaceId);
|
||||
const current = getSettingsFromStore(store, searchSpaceId);
|
||||
const nextMode =
|
||||
settings.mode === "cloud" || settings.mode === "desktop_local_folder"
|
||||
? settings.mode
|
||||
|
|
@ -113,8 +180,15 @@ export async function setAgentFilesystemSettings(
|
|||
|
||||
const settingsPath = getSettingsPath();
|
||||
await mkdir(dirname(settingsPath), { recursive: true });
|
||||
await writeFile(settingsPath, JSON.stringify(next, null, 2), "utf8");
|
||||
cachedSettings = next;
|
||||
const nextStore: AgentFilesystemSettingsStore = {
|
||||
version: 2,
|
||||
spaces: {
|
||||
...store.spaces,
|
||||
[key]: next,
|
||||
},
|
||||
};
|
||||
await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8");
|
||||
cachedSettingsStore = nextStore;
|
||||
return next;
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +234,20 @@ export type LocalRootMount = {
|
|||
rootPath: string;
|
||||
};
|
||||
|
||||
export type AgentFilesystemListOptions = {
|
||||
rootPath: string;
|
||||
searchSpaceId?: number | null;
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
};
|
||||
|
||||
export type AgentFilesystemFileEntry = {
|
||||
relativePath: string;
|
||||
fullPath: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
};
|
||||
|
||||
function sanitizeMountName(rawMount: string): string {
|
||||
const normalized = rawMount
|
||||
.trim()
|
||||
|
|
@ -188,11 +276,111 @@ function buildRootMounts(rootPaths: string[]): LocalRootMount[] {
|
|||
return mounts;
|
||||
}
|
||||
|
||||
export async function getAgentFilesystemMounts(): Promise<LocalRootMount[]> {
|
||||
const rootPaths = await resolveCurrentRootPaths();
|
||||
export async function getAgentFilesystemMounts(
|
||||
searchSpaceId?: number | null
|
||||
): Promise<LocalRootMount[]> {
|
||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
||||
return buildRootMounts(rootPaths);
|
||||
}
|
||||
|
||||
function normalizeComparablePath(pathValue: string): string {
|
||||
const normalized = resolve(pathValue);
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function normalizeExtensionSet(fileExtensions: string[] | null | undefined): Set<string> | null {
|
||||
if (!fileExtensions || fileExtensions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const set = new Set<string>();
|
||||
for (const extension of fileExtensions) {
|
||||
if (typeof extension !== "string") continue;
|
||||
const trimmed = extension.trim().toLowerCase();
|
||||
if (!trimmed) continue;
|
||||
set.add(trimmed.startsWith(".") ? trimmed : `.${trimmed}`);
|
||||
}
|
||||
return set.size > 0 ? set : null;
|
||||
}
|
||||
|
||||
function normalizeExcludeSet(excludePatterns: string[] | null | undefined): Set<string> {
|
||||
const set = new Set<string>();
|
||||
for (const pattern of excludePatterns ?? []) {
|
||||
if (typeof pattern !== "string") continue;
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) continue;
|
||||
set.add(trimmed);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
export async function listAgentFilesystemFiles(
|
||||
options: AgentFilesystemListOptions
|
||||
): Promise<AgentFilesystemFileEntry[]> {
|
||||
const allowedRootPaths = await resolveCurrentRootPaths(options.searchSpaceId);
|
||||
const requestedRootPath = await canonicalizeRootPath(options.rootPath);
|
||||
const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath);
|
||||
const allowedRoots = new Set(
|
||||
(
|
||||
await Promise.all(allowedRootPaths.map((rootPath) => canonicalizeRootPath(rootPath)))
|
||||
).map((rootPath) => normalizeComparablePath(rootPath))
|
||||
);
|
||||
if (!allowedRoots.has(normalizedRequestedRoot)) {
|
||||
throw new Error("Selected path is not an allowed local root");
|
||||
}
|
||||
|
||||
const excludePatterns = normalizeExcludeSet(options.excludePatterns);
|
||||
const extensionSet = normalizeExtensionSet(options.fileExtensions);
|
||||
const files: AgentFilesystemFileEntry[] = [];
|
||||
const stack: string[] = [requestedRootPath];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const currentDir = stack.pop();
|
||||
if (!currentDir) continue;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(currentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || excludePatterns.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const absolutePath = join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (extensionSet) {
|
||||
const extension = extname(entry.name).toLowerCase();
|
||||
if (!extensionSet.has(extension)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const fileStat = await stat(absolutePath);
|
||||
if (!fileStat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
relativePath: relative(requestedRootPath, absolutePath).replace(/\\/g, "/"),
|
||||
fullPath: absolutePath,
|
||||
size: fileStat.size,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
});
|
||||
} catch {
|
||||
// Files can disappear while scanning.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseMountedVirtualPath(
|
||||
virtualPath: string,
|
||||
mounts: LocalRootMount[]
|
||||
|
|
@ -231,8 +419,8 @@ function toMountedVirtualPath(mount: string, rootPath: string, absolutePath: str
|
|||
return `/${mount}${relativePath}`;
|
||||
}
|
||||
|
||||
async function resolveCurrentRootPaths(): Promise<string[]> {
|
||||
const settings = await getAgentFilesystemSettings();
|
||||
async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<string[]> {
|
||||
const settings = await getAgentFilesystemSettings(searchSpaceId);
|
||||
if (settings.localRootPaths.length === 0) {
|
||||
throw new Error("No local filesystem roots selected");
|
||||
}
|
||||
|
|
@ -240,9 +428,10 @@ async function resolveCurrentRootPaths(): Promise<string[]> {
|
|||
}
|
||||
|
||||
export async function readAgentLocalFileText(
|
||||
virtualPath: string
|
||||
virtualPath: string,
|
||||
searchSpaceId?: number | null
|
||||
): Promise<{ path: string; content: string }> {
|
||||
const rootPaths = await resolveCurrentRootPaths();
|
||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
||||
const mounts = buildRootMounts(rootPaths);
|
||||
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
||||
const rootMount = findMountByName(mounts, mount);
|
||||
|
|
@ -261,9 +450,10 @@ export async function readAgentLocalFileText(
|
|||
|
||||
export async function writeAgentLocalFileText(
|
||||
virtualPath: string,
|
||||
content: string
|
||||
content: string,
|
||||
searchSpaceId?: number | null
|
||||
): Promise<{ path: string }> {
|
||||
const rootPaths = await resolveCurrentRootPaths();
|
||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
||||
const mounts = buildRootMounts(rootPaths);
|
||||
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
||||
const rootMount = findMountByName(mounts, mount);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue