fix(memory-workspace): detect Obsidian vault registration before deep link (#2638)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-05-26 02:25:21 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent 0e4729e7f2
commit e05cab9bb2
26 changed files with 1274 additions and 118 deletions
@@ -29,7 +29,6 @@ import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { ToastNotification } from '../../types/intelligence';
import { openUrl } from '../../utils/openUrl';
import {
type GraphExportResponse,
type GraphMode,
@@ -38,10 +37,9 @@ import {
memoryTreeResetTree,
memoryTreeWipeAll,
} from '../../utils/tauriCommands';
import { revealWorkspacePath } from '../../utils/tauriCommands/workspacePaths';
import { MemoryGraph } from './MemoryGraph';
import { MemorySources } from './MemorySources';
import { MEMORY_CONTENT_WORKSPACE_PATH } from './memoryWorkspacePaths';
import { ObsidianVaultSection } from './ObsidianVaultSection';
import { VaultPanel } from './VaultPanel';
import { WhatsAppMemorySection } from './WhatsAppMemorySection';
@@ -62,34 +60,6 @@ interface MemoryWorkspaceProps {
*/
const SYNCABLE_TOOLKITS: ReadonlySet<string> = new Set(['gmail']);
/**
* Trigger the `obsidian://open?path=<abs>` deep link via the OS shell.
*
* We deliberately route through `openUrl` (which delegates to
* `tauri-plugin-opener`) rather than setting `window.location.href`.
* The webview-host intent handler intercepts in-app navigations and
* does NOT punt custom schemes to the OS, so a direct
* `window.location.href = "obsidian://…"` either no-ops or navigates
* the React app away from the Memory tab. The opener plugin hands the
* URL straight to the system handler so Obsidian launches as a
* separate process.
*
* Returns `null` on success, or the underlying error otherwise. The
* caller decides how to surface the outcome (toast, fallback, …); an
* unsurfaced no-op is the bug #2281 originally reported.
*/
async function openVaultInObsidian(contentRootAbs: string): Promise<unknown | null> {
const url = `obsidian://open?path=${encodeURIComponent(contentRootAbs)}`;
console.debug('[ui-flow][memory-workspace] open vault in Obsidian url=%s', url);
try {
await openUrl(url);
return null;
} catch (err) {
console.error('[ui-flow][memory-workspace] openUrl failed', err);
return err;
}
}
export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
const { t } = useT();
const [graph, setGraph] = useState<GraphExportResponse | null>(null);
@@ -242,48 +212,6 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
}
}, [onToast, mode]);
// #2281: clicking "View Vault" must never silently no-op. Some
// users don't have Obsidian installed, in which case the OS shell
// accepts the `obsidian://` URL and does nothing visible. We always
// emit a toast that names the vault path AND offers a "Reveal
// Folder" action so the user has an OS-native way to inspect the
// vault even without Obsidian.
const handleViewVault = useCallback(
async (contentRootAbs: string) => {
const revealHandler = () => {
void (async () => {
try {
await revealWorkspacePath(MEMORY_CONTENT_WORKSPACE_PATH);
} catch (err) {
console.error('[ui-flow][memory-workspace] revealWorkspacePath failed', err);
onToast?.({
type: 'error',
title: t('workspace.revealVaultFailed'),
message: err instanceof Error ? err.message : String(err),
});
}
})();
};
const err = await openVaultInObsidian(contentRootAbs);
if (err === null) {
onToast?.({
type: 'info',
title: t('workspace.openingVaultTitle'),
message: `${t('workspace.openingVaultMessage')} ${contentRootAbs}`,
action: { label: t('workspace.revealFolder'), handler: revealHandler },
});
} else {
onToast?.({
type: 'error',
title: t('workspace.openVaultFailedTitle'),
message: `${t('workspace.openVaultFailedMessage')} ${contentRootAbs}`,
action: { label: t('workspace.revealFolder'), handler: revealHandler },
});
}
},
[onToast, t]
);
return (
<div className="space-y-4" data-testid="memory-workspace">
<MemorySources syncableToolkits={SYNCABLE_TOOLKITS} pollIntervalMs={5000} onToast={onToast} />
@@ -358,18 +286,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
)}
</button>
{graph && (
<button
type="button"
onClick={() => void handleViewVault(graph.content_root_abs)}
data-testid="memory-open-in-obsidian"
className="inline-flex items-center gap-2 rounded-lg
bg-violet-500 px-4 py-2 text-sm font-semibold text-white
shadow-sm transition-colors hover:bg-violet-600
focus:outline-none focus:ring-2 focus:ring-violet-300"
title={`obsidian://open?path=${graph.content_root_abs}`}>
<ExternalLinkIcon />
{t('workspace.viewVault')}
</button>
<ObsidianVaultSection contentRootAbs={graph.content_root_abs} onToast={onToast} />
)}
</div>
</div>
@@ -491,25 +408,6 @@ function BrainIcon() {
);
}
function ExternalLinkIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M14 3h7v7" />
<path d="M10 14L21 3" />
<path d="M21 14v7H3V3h7" />
</svg>
);
}
function Spinner() {
return (
<svg
@@ -0,0 +1,307 @@
/**
* Inline "open your memory vault in Obsidian" control for the Memory tab.
*
* The vault is the on-disk `<workspace>/memory_tree/content/` folder. Opening
* it uses an `obsidian://open?path=…` deep link — but that scheme only
* resolves folders Obsidian has already registered as a vault (it cannot
* register one, and a `.obsidian/` folder on disk is not enough). So a
* first-time click would otherwise land on Obsidian's *"Unable to find a vault
* for the URL"* error.
*
* Flow (progressive disclosure):
* - Click → check registration via `memory_tree_obsidian_vault_status`.
* - Registered → fire the deep link (success toast). Done.
* - Not registered → expand inline guidance instead of firing a doomed link:
* Reveal Folder · Open anyway · Install Obsidian · Advanced ▸ config-dir
* override.
*
* Detection is best-effort — Obsidian can live somewhere we can't probe
* (Flatpak/Snap/portable). "Open anyway" and the config-dir override are the
* escape hatches for that case; a false "not registered" never blocks the user.
*/
import { useCallback, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { ToastNotification } from '../../types/intelligence';
import { openUrl } from '../../utils/openUrl';
import { memoryTreeObsidianVaultStatus } from '../../utils/tauriCommands';
import { revealWorkspacePath } from '../../utils/tauriCommands/workspacePaths';
import { MEMORY_CONTENT_WORKSPACE_PATH } from './memoryWorkspacePaths';
/** localStorage key for the optional Obsidian config-dir override. */
const CONFIG_DIR_KEY = 'openhuman.obsidian.configDir';
const OBSIDIAN_DOWNLOAD_URL = 'https://obsidian.md/download';
interface ObsidianVaultSectionProps {
/** Absolute path to `<workspace>/memory_tree/content/` (from graph export). */
contentRootAbs: string;
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
}
function readConfigDirOverride(): string {
try {
return localStorage.getItem(CONFIG_DIR_KEY) ?? '';
} catch {
return '';
}
}
export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultSectionProps) {
const { t } = useT();
const [checking, setChecking] = useState(false);
const [expanded, setExpanded] = useState(false);
// null = not probed yet / probe failed; otherwise last `config_found`.
const [configFound, setConfigFound] = useState<boolean | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [configDir, setConfigDir] = useState<string>(readConfigDirOverride);
/** Build + fire the `obsidian://` deep link. Resolves to an error or null. */
const fireDeepLink = useCallback(async (): Promise<unknown | null> => {
const url = `obsidian://open?path=${encodeURIComponent(contentRootAbs)}`;
console.debug('[ui-flow][obsidian-vault] firing deep link');
try {
await openUrl(url);
return null;
} catch (err) {
console.error('[ui-flow][obsidian-vault] openUrl failed', err);
return err;
}
}, [contentRootAbs]);
const reveal = useCallback(() => {
void (async () => {
try {
await revealWorkspacePath(MEMORY_CONTENT_WORKSPACE_PATH);
} catch (err) {
console.error('[ui-flow][obsidian-vault] revealWorkspacePath failed', err);
onToast?.({
type: 'error',
title: t('workspace.revealVaultFailed'),
message: err instanceof Error ? err.message : String(err),
});
}
})();
}, [onToast, t]);
const toastOpenOutcome = useCallback(
(err: unknown | null) => {
onToast?.(
err === null
? {
type: 'info',
title: t('workspace.openingVaultTitle'),
message: `${t('workspace.openingVaultMessage')} ${contentRootAbs}`,
action: { label: t('workspace.revealFolder'), handler: reveal },
}
: {
type: 'error',
title: t('workspace.openVaultFailedTitle'),
message: `${t('workspace.openVaultFailedMessage')} ${contentRootAbs}`,
action: { label: t('workspace.revealFolder'), handler: reveal },
}
);
},
[onToast, t, contentRootAbs, reveal]
);
/** Fire the deep link unconditionally — the "Open anyway" escape hatch. */
const openAnyway = useCallback(() => {
void (async () => {
toastOpenOutcome(await fireDeepLink());
})();
}, [fireDeepLink, toastOpenOutcome]);
const installObsidian = useCallback(() => {
// https URL → openUrl falls back to window.open if the IPC bridge isn't
// ready, so this normally reaches the download page. Swallow + log any
// rejection so it can't surface as an unhandled promise rejection.
void (async () => {
try {
await openUrl(OBSIDIAN_DOWNLOAD_URL);
} catch (err) {
console.error('[ui-flow][obsidian-vault] openUrl(download) failed', err);
}
})();
}, []);
const handleViewVault = useCallback(() => {
void (async () => {
setChecking(true);
try {
const override = configDir.trim();
const status = await memoryTreeObsidianVaultStatus(override || undefined);
console.debug(
'[ui-flow][obsidian-vault] status registered=%s config_found=%s',
status.registered,
status.config_found
);
setConfigFound(status.config_found);
if (status.registered) {
// Known vault — open it directly.
const err = await fireDeepLink();
toastOpenOutcome(err);
// Registered but the IPC/scheme still failed → surface guidance;
// on success collapse any stale panel from a prior failed check.
setExpanded(err !== null);
return;
}
// Not registered — guide the user instead of firing a doomed link.
setExpanded(true);
} catch (err) {
// Detection itself failed — degrade gracefully to the guidance panel.
console.error('[ui-flow][obsidian-vault] status check failed', err);
setConfigFound(null);
setExpanded(true);
} finally {
setChecking(false);
}
})();
}, [configDir, fireDeepLink, toastOpenOutcome]);
const saveConfigDir = useCallback(() => {
const trimmed = configDir.trim();
try {
if (trimmed) localStorage.setItem(CONFIG_DIR_KEY, trimmed);
else localStorage.removeItem(CONFIG_DIR_KEY);
} catch (err) {
console.warn('[ui-flow][obsidian-vault] persist config dir failed', err);
}
// Re-run the check with the new override applied.
handleViewVault();
}, [configDir, handleViewVault]);
const helpText =
configFound === false
? t('workspace.obsidianNotFoundHelp')
: t('workspace.vaultNotRegisteredHelp');
return (
<div className="flex flex-col items-end gap-2" data-testid="obsidian-vault-section">
<button
type="button"
onClick={handleViewVault}
disabled={checking}
data-testid="memory-open-in-obsidian"
className="inline-flex items-center gap-2 rounded-lg
bg-violet-500 px-4 py-2 text-sm font-semibold text-white
shadow-sm transition-colors hover:bg-violet-600
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-violet-300"
title={`obsidian://open?path=${contentRootAbs}`}>
<ExternalLinkIcon />
{checking ? t('workspace.checkingVault') : t('workspace.viewVault')}
</button>
{expanded && (
<div
data-testid="obsidian-vault-guidance"
className="w-full max-w-xl rounded-lg border border-violet-200 bg-violet-50 p-4
text-sm dark:border-violet-500/30 dark:bg-violet-500/10">
<p className="text-neutral-700 dark:text-neutral-200">{helpText}</p>
<code
className="mt-2 block break-all rounded bg-white/70 px-2 py-1 font-mono text-xs
text-neutral-600 dark:bg-neutral-900/60 dark:text-neutral-300"
data-testid="obsidian-vault-path">
{contentRootAbs}
</code>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={reveal}
data-testid="obsidian-reveal"
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-semibold
text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600
dark:bg-neutral-800 dark:text-neutral-200">
{t('workspace.revealFolder')}
</button>
<button
type="button"
onClick={openAnyway}
data-testid="obsidian-open-anyway"
className="rounded-md border border-violet-300 bg-white px-3 py-1.5 text-xs font-semibold
text-violet-700 hover:bg-violet-50 dark:border-violet-500/40
dark:bg-neutral-800 dark:text-violet-300">
{t('workspace.openAnyway')}
</button>
<button
type="button"
onClick={installObsidian}
data-testid="obsidian-install"
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-semibold
text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600
dark:bg-neutral-800 dark:text-neutral-200">
{t('workspace.installObsidian')}
</button>
</div>
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
data-testid="obsidian-advanced-toggle"
className="mt-3 text-xs font-medium text-violet-600 hover:underline dark:text-violet-300">
{t('workspace.obsidianAdvanced')}
</button>
{showAdvanced && (
<div className="mt-2 space-y-1.5">
<label
htmlFor="obsidian-config-dir"
className="block text-xs font-medium text-neutral-600 dark:text-neutral-300">
{t('workspace.obsidianConfigDirLabel')}
</label>
<div className="flex flex-wrap items-center gap-2">
<input
id="obsidian-config-dir"
type="text"
value={configDir}
onChange={e => setConfigDir(e.target.value)}
placeholder={t('workspace.obsidianConfigDirPlaceholder')}
spellCheck={false}
data-testid="obsidian-config-dir-input"
className="flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-mono text-xs
text-neutral-800 focus:outline-none focus:ring-1 focus:ring-violet-300
dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100"
/>
<button
type="button"
onClick={saveConfigDir}
disabled={checking}
data-testid="obsidian-config-dir-save"
className="rounded-md bg-violet-500 px-3 py-1 text-xs font-semibold text-white
hover:bg-violet-600 disabled:opacity-50">
{t('common.save')}
</button>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('workspace.obsidianConfigDirHint')}
</p>
</div>
)}
</div>
)}
</div>
);
}
function ExternalLinkIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M14 3h7v7" />
<path d="M10 14L21 3" />
<path d="M21 14v7H3V3h7" />
</svg>
);
}
@@ -15,6 +15,7 @@ vi.mock('../../../utils/tauriCommands', () => ({
memoryTreeFlushNow: vi.fn(),
memoryTreeWipeAll: vi.fn(),
memoryTreeResetTree: vi.fn(),
memoryTreeObsidianVaultStatus: vi.fn(),
}));
vi.mock('../../../services/memorySyncService', () => ({
@@ -45,13 +46,19 @@ vi.mock('../../../utils/tauriCommands/workspacePaths', () => ({
}),
}));
const { memoryTreeGraphExport, memoryTreeFlushNow, memoryTreeWipeAll, memoryTreeResetTree } =
(await import('../../../utils/tauriCommands')) as unknown as {
memoryTreeGraphExport: Mock;
memoryTreeFlushNow: Mock;
memoryTreeWipeAll: Mock;
memoryTreeResetTree: Mock;
};
const {
memoryTreeGraphExport,
memoryTreeFlushNow,
memoryTreeWipeAll,
memoryTreeResetTree,
memoryTreeObsidianVaultStatus,
} = (await import('../../../utils/tauriCommands')) as unknown as {
memoryTreeGraphExport: Mock;
memoryTreeFlushNow: Mock;
memoryTreeWipeAll: Mock;
memoryTreeResetTree: Mock;
memoryTreeObsidianVaultStatus: Mock;
};
const { listConnections, syncConnection } =
(await import('../../../lib/composio/composioApi')) as unknown as {
@@ -115,6 +122,14 @@ describe('MemoryWorkspace (graph view)', () => {
openUrl.mockResolvedValue(undefined);
openWorkspacePath.mockResolvedValue(undefined);
revealWorkspacePath.mockResolvedValue(undefined);
// Default: the content root is already a registered Obsidian vault, so a
// View-Vault click opens it directly (the not-registered guidance branch
// is covered in ObsidianVaultSection.test.tsx).
memoryTreeObsidianVaultStatus.mockResolvedValue({
registered: true,
config_found: true,
content_root_abs: '/tmp/workspace/memory_tree/content',
});
});
it('renders the SVG graph once the export RPC resolves', async () => {
@@ -0,0 +1,125 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import { ObsidianVaultSection } from '../ObsidianVaultSection';
vi.mock('../../../utils/tauriCommands', () => ({ memoryTreeObsidianVaultStatus: vi.fn() }));
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../../../utils/tauriCommands/workspacePaths', () => ({
revealWorkspacePath: vi.fn().mockResolvedValue(undefined),
}));
const { memoryTreeObsidianVaultStatus } =
(await import('../../../utils/tauriCommands')) as unknown as {
memoryTreeObsidianVaultStatus: Mock;
};
const { openUrl } = (await import('../../../utils/openUrl')) as unknown as { openUrl: Mock };
const { revealWorkspacePath } =
(await import('../../../utils/tauriCommands/workspacePaths')) as unknown as {
revealWorkspacePath: Mock;
};
const ROOT = '/tmp/workspace/memory_tree/content';
const DEEP_LINK = 'obsidian://open?path=' + encodeURIComponent(ROOT);
function status(over: Partial<{ registered: boolean; config_found: boolean }> = {}) {
return { registered: false, config_found: true, content_root_abs: ROOT, ...over };
}
describe('ObsidianVaultSection', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
openUrl.mockResolvedValue(undefined);
revealWorkspacePath.mockResolvedValue(undefined);
});
it('registered vault → opens the deep link directly, no guidance shown', async () => {
memoryTreeObsidianVaultStatus.mockResolvedValue(status({ registered: true }));
const onToast = vi.fn();
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} onToast={onToast} />);
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(DEEP_LINK));
expect(screen.queryByTestId('obsidian-vault-guidance')).toBeNull();
await waitFor(() => expect(onToast).toHaveBeenCalled());
expect(onToast.mock.calls[0][0].type).toBe('info');
});
it('unregistered vault → no deep link, shows guidance with the vault path', async () => {
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
await waitFor(() => expect(screen.getByTestId('obsidian-vault-guidance')).toBeInTheDocument());
expect(openUrl).not.toHaveBeenCalled();
expect(screen.getByTestId('obsidian-vault-path')).toHaveTextContent(ROOT);
});
it('"Open anyway" fires the deep link even when unregistered', async () => {
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
const onToast = vi.fn();
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} onToast={onToast} />);
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
const openAnyway = await screen.findByTestId('obsidian-open-anyway');
fireEvent.click(openAnyway);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(DEEP_LINK));
});
it('"Reveal Folder" in the guidance panel reveals the content root', async () => {
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
fireEvent.click(await screen.findByTestId('obsidian-reveal'));
await waitFor(() => expect(revealWorkspacePath).toHaveBeenCalledWith('memory_tree/content'));
});
it('config not found → Install Obsidian opens the download page', async () => {
memoryTreeObsidianVaultStatus.mockResolvedValue(status({ config_found: false }));
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
fireEvent.click(await screen.findByTestId('obsidian-install'));
await waitFor(() => expect(openUrl).toHaveBeenCalledWith('https://obsidian.md/download'));
});
it('Advanced config-dir override persists to localStorage and re-checks with it', async () => {
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
// First click → not registered → guidance.
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
fireEvent.click(await screen.findByTestId('obsidian-advanced-toggle'));
const input = await screen.findByTestId('obsidian-config-dir-input');
fireEvent.change(input, { target: { value: '/custom/obsidian' } });
fireEvent.click(screen.getByTestId('obsidian-config-dir-save'));
await waitFor(() =>
expect(memoryTreeObsidianVaultStatus).toHaveBeenLastCalledWith('/custom/obsidian')
);
expect(localStorage.getItem('openhuman.obsidian.configDir')).toBe('/custom/obsidian');
});
it('detection failure degrades gracefully to the guidance panel', async () => {
memoryTreeObsidianVaultStatus.mockRejectedValue(new Error('rpc down'));
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
await waitFor(() => expect(screen.getByTestId('obsidian-vault-guidance')).toBeInTheDocument());
expect(openUrl).not.toHaveBeenCalled();
});
});
+12
View File
@@ -40,6 +40,18 @@ const ar3: TranslationMap = {
'workspace.openVaultFailedMessage': 'استخدم Reveal Folder لفتح دليل المخزن مباشرة. مسار المدفن:',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': 'كشف المجلد',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'فشل تحميل الرسم البياني للذاكرة',
'workspace.loadingGraph': 'جارٍ تحميل الرسم البياني للذاكرة...',
'workspace.graphViewMode': 'وضع عرض الرسم البياني للذاكرة',
+12
View File
@@ -41,6 +41,18 @@ const bn3: TranslationMap = {
'ভল্ট ডিরেক্টরিটি সরাসরি খুলতে রিভিল ফোল্ডার ব্যবহার করুন। ভল্ট পাথ:',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': 'ফোল্ডার প্রকাশ করুন',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'মেমোরি গ্রাফ লোড করতে ব্যর্থ',
'workspace.loadingGraph': 'মেমোরি গ্রাফ লোড হচ্ছে...',
'workspace.graphViewMode': 'মেমোরি গ্রাফ ভিউ মোড',
+12
View File
@@ -43,6 +43,18 @@ const de3: TranslationMap = {
'Nutze „Ordner anzeigen“, um das Vault-Verzeichnis direkt zu öffnen. Vault-Pfad:',
'workspace.revealVaultFailed': 'Vault-Ordner konnte nicht angezeigt werden',
'workspace.revealFolder': 'Ordner anzeigen',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'Speicherdiagramm konnte nicht geladen werden',
'workspace.loadingGraph': 'Speicherdiagramm wird geladen...',
'workspace.graphViewMode': 'Speicherdiagramm-Ansichtsmodus',
+12
View File
@@ -41,6 +41,18 @@ const en3: TranslationMap = {
'Use Reveal Folder to open the vault directory directly. Vault path:',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': 'Reveal Folder',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'Failed to load memory graph',
'workspace.loadingGraph': 'Loading memory graph...',
'workspace.graphViewMode': 'Memory graph view mode',
+12
View File
@@ -42,6 +42,18 @@ const es3: TranslationMap = {
'Utilice Revelar carpeta para abrir el directorio del almacén directamente. Ruta de la bóveda:',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': 'Revelar carpeta',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'No se pudo cargar el grafo de memoria',
'workspace.loadingGraph': 'Cargando grafo de memoria...',
'workspace.graphViewMode': 'Modo de vista del grafo de memoria',
+12
View File
@@ -43,6 +43,18 @@ const fr3: TranslationMap = {
'Utilisez Reveal Folder pour ouvrir directement le répertoire du coffre-fort. Chemin du coffre-fort :',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': 'Révéler le dossier',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'Échec du chargement du graphe de mémoire',
'workspace.loadingGraph': 'Chargement du graphe de mémoire…',
'workspace.graphViewMode': 'Mode de vue du graphe de mémoire',
+12
View File
@@ -41,6 +41,18 @@ const hi3: TranslationMap = {
'वॉल्ट निर्देशिका को सीधे खोलने के लिए रिवील फोल्डर का उपयोग करें। तिजोरी पथ:',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': 'फ़ोल्डर प्रकट करें',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'मेमोरी ग्राफ लोड नहीं हो पाया',
'workspace.loadingGraph': 'मेमोरी ग्राफ लोड हो रहा है...',
'workspace.graphViewMode': 'मेमोरी ग्राफ व्यू मोड',
+12
View File
@@ -42,6 +42,18 @@ const id3: TranslationMap = {
'Gunakan Tampilkan Folder untuk membuka direktori vault secara langsung. Path vault:',
'workspace.revealVaultFailed': 'Tidak dapat menampilkan folder vault',
'workspace.revealFolder': 'Tampilkan Folder',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'Gagal memuat grafik memori',
'workspace.loadingGraph': 'Memuat grafik memori...',
'workspace.graphViewMode': 'Mode tampilan grafik memori',
+12
View File
@@ -43,6 +43,18 @@ const it3: TranslationMap = {
'Utilizzare Reveal Folder per aprire direttamente la directory del vault. Percorso del vault:',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': 'Rivela cartella',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'Impossibile caricare il grafo di memoria',
'workspace.loadingGraph': 'Caricamento grafo di memoria...',
'workspace.graphViewMode': 'Modalità di visualizzazione grafo di memoria',
+12
View File
@@ -41,6 +41,18 @@ const ko3: TranslationMap = {
'폴더 표시를 사용하여 볼트 디렉터리를 직접 엽니다. 볼트 경로:',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': '폴더 공개',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': '메모리 그래프를 불러오지 못했습니다',
'workspace.loadingGraph': '메모리 그래프를 불러오는 중...',
'workspace.graphViewMode': '메모리 그래프 보기 모드',
+12
View File
@@ -42,6 +42,18 @@ const pt3: TranslationMap = {
'Use Reveal Folder para abrir o diretório do vault diretamente. Caminho do cofre:',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': 'Revelar pasta',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'Falha ao carregar grafo de memória',
'workspace.loadingGraph': 'Carregando grafo de memória...',
'workspace.graphViewMode': 'Modo de visualização do grafo de memória',
+12
View File
@@ -41,6 +41,18 @@ const ru3: TranslationMap = {
'Используйте Reveal Folder, чтобы напрямую открыть каталог хранилища. Путь к хранилищу:',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': 'Показать папку',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'Не удалось загрузить граф памяти',
'workspace.loadingGraph': 'Загрузка графа памяти...',
'workspace.graphViewMode': 'Режим просмотра графа памяти',
+12
View File
@@ -40,6 +40,18 @@ const zhCN3: TranslationMap = {
'workspace.openVaultFailedMessage': '使用"显示文件夹"直接打开存储库目录。存储库路径:',
'workspace.revealVaultFailed': '无法显示存储库文件夹',
'workspace.revealFolder': '显示文件夹',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': '无法加载记忆图谱',
'workspace.loadingGraph': '正在加载记忆图谱...',
'workspace.graphViewMode': '记忆图谱视图模式',
+12
View File
@@ -1586,6 +1586,18 @@ const en: TranslationMap = {
'Use Reveal Folder to open the vault directory directly. Vault path:',
'workspace.revealVaultFailed': "Couldn't reveal vault folder",
'workspace.revealFolder': 'Reveal Folder',
'workspace.checkingVault': 'Checking…',
'workspace.vaultNotRegisteredHelp':
'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.',
'workspace.obsidianNotFoundHelp':
"We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.",
'workspace.openAnyway': 'Open in Obsidian anyway',
'workspace.installObsidian': 'Install Obsidian',
'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?',
'workspace.obsidianConfigDirLabel': 'Obsidian config folder',
'workspace.obsidianConfigDirHint':
'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.',
'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian',
'workspace.graphLoadFailed': 'Failed to load memory graph',
'workspace.loadingGraph': 'Loading memory graph...',
'workspace.graphViewMode': 'Memory graph view mode',
@@ -17,6 +17,7 @@ import {
memoryTreeGraphExport,
memoryTreeListChunks,
memoryTreeListSources,
memoryTreeObsidianVaultStatus,
memoryTreeRecall,
memoryTreeResetTree,
memoryTreeSearch,
@@ -380,3 +381,41 @@ describe('memoryTreeBackfillStatus', () => {
expect(out.pending_jobs).toBe(0);
});
});
describe('memoryTreeObsidianVaultStatus', () => {
test('dispatches with the config-dir override when one is provided', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
result: {
registered: false,
config_found: true,
content_root_abs: '/ws/memory_tree/content',
},
logs: ['memory_tree::read: obsidian_vault_status registered=false config_found=true'],
});
const out = await memoryTreeObsidianVaultStatus('/custom/obsidian');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_obsidian_vault_status',
params: { obsidian_config_dir: '/custom/obsidian' },
});
expect(out.registered).toBe(false);
expect(out.config_found).toBe(true);
});
test('omits the override param and unwraps a bare-value response', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
registered: true,
config_found: true,
content_root_abs: '/ws/memory_tree/content',
});
const out = await memoryTreeObsidianVaultStatus();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_obsidian_vault_status',
params: {},
});
expect(out.registered).toBe(true);
});
});
+47
View File
@@ -604,6 +604,53 @@ export async function memoryTreeGraphExport(
return out;
}
/** Response shape for `memory_tree_obsidian_vault_status`. */
export interface ObsidianVaultStatus {
/**
* True when the content root (or an ancestor) is already a registered
* Obsidian vault, so `obsidian://open?path=` will actually resolve.
*/
registered: boolean;
/**
* True when an `obsidian.json` was found and parsed (Obsidian is set up).
* Lets the UI offer "Open folder as vault" vs. "Install Obsidian".
*/
config_found: boolean;
/** Absolute filesystem path to `<workspace>/memory_tree/content/`. */
content_root_abs: string;
}
/**
* Best-effort check of whether the memory-tree content root is a registered
* Obsidian vault. Called before firing the `obsidian://open?path=` deep link,
* which only resolves vaults already in Obsidian's `obsidian.json` registry —
* it cannot register a new vault on its own.
*
* `obsidianConfigDir` optionally overrides where the core looks for
* `obsidian.json` (non-standard installs: Flatpak / Snap / portable). Backed
* by `openhuman.memory_tree_obsidian_vault_status`.
*/
export async function memoryTreeObsidianVaultStatus(
obsidianConfigDir?: string
): Promise<ObsidianVaultStatus> {
console.debug(
'[memory-tree-rpc] memoryTreeObsidianVaultStatus: entry override=%s',
obsidianConfigDir ? 'set' : 'none'
);
const resp = await callCoreRpc<ObsidianVaultStatus | ResultEnvelope<ObsidianVaultStatus>>({
method: 'openhuman.memory_tree_obsidian_vault_status',
// Only send the override when present so the core uses its default probe.
params: obsidianConfigDir ? { obsidian_config_dir: obsidianConfigDir } : {},
});
const out = unwrapResult(resp);
console.debug(
'[memory-tree-rpc] memoryTreeObsidianVaultStatus: exit registered=%s config_found=%s',
out.registered,
out.config_found
);
return out;
}
/**
* #1574 §4b: per-model embedding re-embed backfill status. The AI settings
* panel polls this after an embedder change to warn that semantic recall