feat: connect Fifth Domain discovery in 0.1.8

This commit is contained in:
冰朔 2026-07-21 14:16:04 +08:00
parent 8ef0497666
commit bc1bc50e66
15 changed files with 256 additions and 49 deletions

View File

@ -181,7 +181,7 @@ flowchart TD
LC --> TM["See You Tomorrow Channel"]
```
`HoloLakeHome` owns only transient navigation state. It does not persist system ownership, server addresses, or credentials. The existing vault remains the filesystem source of truth. From 0.1.7 onward, channel mode owns the full application body; entering Knowledge Lake reveals Tolaria's sidebar and note list as navigation inside that module while a Guanghu module dock remains present. Server registration cards expose stable node identities and future monitoring boundaries without presenting placeholder telemetry as live state. See [ADR 0154](adr/0154-channel-owned-workspace-shell.md).
`HoloLakeHome` owns only transient navigation state. It does not persist system ownership, server addresses, or credentials. The existing vault remains the filesystem source of truth. From 0.1.7 onward, channel mode owns the full application body; entering Knowledge Lake reveals the sidebar and note list as navigation inside that module while a Guanghu module dock remains present. In 0.1.8, `JD-FD-PRIMARY` resolves the public Fifth Domain discovery document without credentials and validates its read-only Guanghu routes. That result proves only public navigation availability; the UI continues to withhold private server-health claims until an authenticated probe exists. See [ADR 0154](adr/0154-channel-owned-workspace-shell.md) and [ADR 0155](adr/0155-fifth-domain-read-only-discovery-and-ai-tool-boundary.md).
```mermaid
flowchart TD
@ -391,7 +391,7 @@ Large active notes are compacted into a head/tail body snapshot before they ente
### Direct Model Targets
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive shell access. OpenAI-compatible direct targets can use Tolaria's narrow native `create_note` tool when an active vault is loaded; the tool calls the same create-only, active-vault-bounded note write command as the UI and emits tool events so the renderer refreshes and opens the created note. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
HoloLake Era also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive an embedded note-context snapshot and conversation history, but they do not receive shell or vault-read tools. Their system identity names them as the current AI instance operating the Guanghu language-personality system, and capability-aware context never directs them to invent `get_note` or `read_file`. OpenAI-compatible direct targets can use the narrow native `create_note` tool when an active vault is loaded; unknown tool requests perform no file operation and become a nonfatal assistant explanation. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints. See [ADR 0155](adr/0155-fifth-domain-read-only-discovery-and-ai-tool-boundary.md).
Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Env-backed provider keys are resolved from the app process first, then from exported values in the user's zsh/bash startup files on Unix so GUI-launched sessions can still use shell-managed secrets. Local endpoints can omit authentication.

View File

@ -0,0 +1,24 @@
# ADR 0155: Fifth Domain read-only discovery and AI tool boundary
## Status
Accepted for HoloLake Era 0.1.8.
## Context
The 0.1.7 channel shell registered `JD-FD-PRIMARY` but exposed only a placeholder. Direct API models were also given vault-tool instructions even though their native tool surface only advertised `create_note`. Some providers consequently invented `read_file`, and the backend converted that mismatch into a fatal chat error.
## Decision
HoloLake Era reads `https://guanghulab.com/.well-known/guanghu.json` without credentials and accepts it only when the schema is `guanghu.ai-discovery/v1`, access is `public-read-only`, and every route remains under trusted Guanghu HTTPS. The UI reports this as a public route connection, never as proof that the private control server is healthy.
AI context is capability-aware. Coding agents retain vault-tool guidance. Direct API models receive only embedded note context and are told not to invent unavailable tools. If a provider still requests an unknown tool, the backend performs no file access and returns a nonfatal explanatory response.
The system identity preamble now identifies the model as the current AI instance operating the Guanghu language-personality system inside HoloLake Era; it no longer presents Tolaria as the system authority.
## Consequences
- Fifth Domain navigation has a real, read-only discovery connection without credentials or write authority.
- Public discovery availability and private server health remain distinct facts.
- Unknown direct-model tools cannot terminate the conversation or touch files.
- A future authenticated identity/router client can extend this boundary through a separate ADR and human authorization flow.

View File

@ -2,7 +2,7 @@
"name": "hololake-era",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"scripts": {
"dev": "vite",

2
src-tauri/Cargo.lock generated
View File

@ -1865,7 +1865,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hololake"
version = "0.1.7"
version = "0.1.8"
dependencies = [
"base64 0.22.1",
"chrono",

View File

@ -1,6 +1,6 @@
[package]
name = "hololake"
version = "0.1.7"
version = "0.1.8"
description = "Personal knowledge and life management app"
authors = ["Luca Rossi"]
license = "AGPL-3.0-or-later"

View File

@ -135,8 +135,8 @@ where
F: FnMut(AiAgentStreamEvent),
{
if tool_call.name != CREATE_NOTE_TOOL_NAME {
return Err(format!(
"AI provider requested unsupported tool: {}",
return Ok(format!(
"The model requested an unavailable tool ({}). No file was accessed. Please retry using the visible note context, or switch to a coding agent for vault file tools.",
tool_call.name
));
}
@ -535,7 +535,7 @@ mod tests {
}
#[test]
fn execute_openai_tool_calls_rejects_unsupported_tool_before_running_it() {
fn execute_openai_tool_calls_reports_unsupported_tool_without_crashing_chat() {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
let response = tool_call_response(json!({
@ -547,10 +547,13 @@ mod tests {
}));
let mut events = Vec::new();
let error =
execute_openai_tool_calls(&request, &response, |event| events.push(event)).unwrap_err();
let summary =
execute_openai_tool_calls(&request, &response, |event| events.push(event)).unwrap();
assert_eq!(error, "AI provider requested unsupported tool: delete_note");
assert_eq!(
summary.as_deref(),
Some("The model requested an unavailable tool (delete_note). No file was accessed. Please retry using the visible note context, or switch to a coding agent for vault file tools.")
);
assert!(events.is_empty());
}

View File

@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HoloLake Era",
"version": "0.1.7",
"version": "0.1.8",
"identifier": "com.guanghu.desktop",
"build": {
"frontendDist": "../dist",

View File

@ -1,17 +1,29 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { HoloLakeHome, HOLOLAKE_DEVELOPMENT_REPOSITORY_URL } from './HoloLakeHome'
const { openExternalUrlMock, trackEventMock } = vi.hoisted(() => ({
const { fetchFifthDomainDiscoveryMock, openExternalUrlMock, trackEventMock } = vi.hoisted(() => ({
fetchFifthDomainDiscoveryMock: vi.fn(),
openExternalUrlMock: vi.fn().mockResolvedValue(undefined),
trackEventMock: vi.fn(),
}))
vi.mock('../utils/url', () => ({ openExternalUrl: openExternalUrlMock }))
vi.mock('../lib/telemetry', () => ({ trackEvent: trackEventMock }))
vi.mock('../lib/fifthDomainDiscovery', () => ({
fetchFifthDomainDiscovery: fetchFifthDomainDiscoveryMock,
}))
describe('HoloLakeHome', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
fetchFifthDomainDiscoveryMock.mockResolvedValue({
access: 'public-read-only',
canonicalRepository: 'https://guanghulab.com/fifth-domain/bingshuo/fifth-domain',
name: '光湖语言世界 · 第五域',
nodeMap: 'https://guanghulab.com/api/ai/v1/nodes',
})
})
it('walks the Fifth Domain hierarchy before exposing Eternal Lake Heart subsystems', () => {
render(<HoloLakeHome locale="zh-CN" onEnterKnowledgeBase={vi.fn()} />)
@ -77,7 +89,20 @@ describe('HoloLakeHome', () => {
expect(screen.getByRole('heading', { name: '服务器与灯塔节点' })).toBeInTheDocument()
expect(screen.getByText('BS-SG-001')).toBeInTheDocument()
expect(screen.getByText('JD-FD-PRIMARY')).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: /接口已预留/ })).toHaveLength(3)
expect(screen.getAllByRole('button', { name: /接口已预留/ })).toHaveLength(2)
})
it('connects the Fifth Domain public read-only route without claiming private server health', async () => {
render(<HoloLakeHome locale="zh-CN" onEnterKnowledgeBase={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: '服务器与节点' }))
await waitFor(() => expect(screen.getByText('公开只读路由已连接')).toBeInTheDocument())
expect(screen.getByText('第五域国内主控节点')).toBeInTheDocument()
expect(screen.getByText('未验证私有服务器运行状态')).toBeInTheDocument()
expect(trackEventMock).toHaveBeenCalledWith('fifth_domain_connection_checked', {
access: 'public-read-only',
result: 'connected',
})
})
it('provides direct channel navigation for the iPhone layout', () => {

View File

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { fetchFifthDomainDiscovery, type FifthDomainDiscovery } from '../lib/fifthDomainDiscovery'
import type { AppLocale, TranslationKey } from '../lib/i18n'
import { translate } from '../lib/i18n'
import { trackEvent } from '../lib/telemetry'
@ -59,12 +60,15 @@ function PersonaRoute({ id, title }: { id: string; title: string }) {
const registeredServers = [
{ id: 'BS-SG-001', label: '新加坡大脑服务器', owner: '冰朔 · 永恒湖心', status: '已接入', tone: 'online' },
{ id: 'BS-GZ-001', label: '广州个人服务器', owner: '冰朔 · 心跳核心', status: '接口预留', tone: 'pending' },
{ id: 'JD-FD-PRIMARY', label: '第五域企业主节点', owner: '第五域 · 零点原核', status: '接口预留', tone: 'pending' },
{ id: 'JD-FD-PRIMARY', label: '第五域国内主控节点', owner: '第五域 · 零点原核', status: '公开路由检查', tone: 'pending' },
] as const
export function HoloLakeHome({ locale, onEnterKnowledgeBase }: HoloLakeHomeProps) {
const [route, setRoute] = useState<ChannelRoute>('zero-core')
const [architectureOpen, setArchitectureOpen] = useState(false)
const [fifthDomainConnection, setFifthDomainConnection] = useState<
{ status: 'checking' | 'error' } | { status: 'connected'; discovery: FifthDomainDiscovery }
>({ status: 'checking' })
const t = (key: TranslationKey) => translate(locale, key)
const navigate = (nextRoute: ChannelRoute) => {
@ -87,6 +91,20 @@ export function HoloLakeHome({ locale, onEnterKnowledgeBase }: HoloLakeHomeProps
onEnterKnowledgeBase()
}
useEffect(() => {
if (route !== 'servers' || fifthDomainConnection.status !== 'checking') return
const controller = new AbortController()
void fetchFifthDomainDiscovery(fetch, controller.signal).then(discovery => {
setFifthDomainConnection({ status: 'connected', discovery })
trackEvent('fifth_domain_connection_checked', { access: discovery.access, result: 'connected' })
}).catch(() => {
if (controller.signal.aborted) return
setFifthDomainConnection({ status: 'error' })
trackEvent('fifth_domain_connection_checked', { access: 'public-read-only', result: 'error' })
})
return () => controller.abort()
}, [fifthDomainConnection.status, route])
const renderRoute = () => {
if (route === 'zero-core') {
return (
@ -158,11 +176,18 @@ export function HoloLakeHome({ locale, onEnterKnowledgeBase }: HoloLakeHomeProps
<div className="server-grid">
{registeredServers.map(server => (
<article className="server-card" key={server.id}>
<div className="server-card__head"><code>{server.id}</code><span data-tone={server.tone}>{server.status}</span></div>
<div className="server-card__head"><code>{server.id}</code><span data-tone={server.id === 'JD-FD-PRIMARY' && fifthDomainConnection.status === 'connected' ? 'online' : server.tone}>{server.id === 'JD-FD-PRIMARY'
? fifthDomainConnection.status === 'connected' ? '公开只读路由已连接' : fifthDomainConnection.status === 'error' ? '公开路由连接失败' : '正在检查公开路由'
: server.status}</span></div>
<h2>{server.label}</h2>
<p>{server.owner}</p>
<dl><div><dt></dt><dd></dd></div><div><dt></dt><dd></dd></div></dl>
<Button variant="outline" disabled> · </Button>
{server.id === 'JD-FD-PRIMARY' ? <>
<dl><div><dt>访</dt><dd></dd></div><div><dt></dt><dd></dd></div></dl>
<Button variant="outline" disabled>{fifthDomainConnection.status === 'connected' ? '公开发现入口可用' : '正在连接公开发现入口'}</Button>
</> : <>
<dl><div><dt></dt><dd></dd></div><div><dt></dt><dd></dd></div></dl>
<Button variant="outline" disabled> · </Button>
</>}
</article>
))}
</div>

View File

@ -6,6 +6,7 @@ import {
type NoteListItem,
} from '../utils/ai-context'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import type { AiTarget } from '../lib/aiTargets'
interface UseAiPanelContextSnapshotArgs {
activeEntry?: VaultEntry | null
@ -15,6 +16,7 @@ interface UseAiPanelContextSnapshotArgs {
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
target?: AiTarget
}
export function useAiPanelContextSnapshot({
@ -25,6 +27,7 @@ export function useAiPanelContextSnapshot({
openTabs,
noteList,
noteListFilter,
target,
}: UseAiPanelContextSnapshotArgs) {
const linkedEntries = useMemo(() => {
if (!activeEntry || !entries) return []
@ -46,8 +49,9 @@ export function useAiPanelContextSnapshot({
noteListFilter,
entries,
references: draftReferences.length > 0 ? draftReferences : undefined,
capabilities: target?.kind === 'api_model' ? 'embedded-context-only' : 'vault-tools',
})
}, [activeEntry, activeNoteContent, draftReferences, entries, noteList, noteListFilter, openTabs])
}, [activeEntry, activeNoteContent, draftReferences, entries, noteList, noteListFilter, openTabs, target])
return { linkedEntries, contextPrompt }
}

View File

@ -176,6 +176,7 @@ export function useAiPanelController({
openTabs,
noteList,
noteListFilter,
target: defaultAiTarget,
})
const { agent, permissionMode } = usePanelAgent({ vaultPath, vaultPaths, contextPrompt, defaultAiAgent, defaultAiTarget, defaultAiAgentReady, defaultAiAgentReadiness, locale, onFileCreated, onFileModified, onVaultChanged, sessionId })

View File

@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
import {
FIFTH_DOMAIN_DISCOVERY_URL,
fetchFifthDomainDiscovery,
parseFifthDomainDiscovery,
} from './fifthDomainDiscovery'
const validDiscovery = {
schema: 'guanghu.ai-discovery/v1',
name: '光湖语言世界 · 第五域',
canonical_repository: 'https://guanghulab.com/fifth-domain/bingshuo/fifth-domain',
repository_map: 'https://guanghulab.com/api/ai/v1/repositories',
server_node_map: 'https://guanghulab.com/api/ai/v1/nodes',
search_api: 'https://guanghulab.com/api/ai/v1/search?q={query}',
resolve_api: 'https://guanghulab.com/api/ai/v1/resolve?id={NUMBER}',
access: 'public-read-only',
}
describe('Fifth Domain discovery', () => {
it('accepts the canonical public read-only discovery document', () => {
expect(parseFifthDomainDiscovery(validDiscovery)).toMatchObject({
access: 'public-read-only',
canonicalRepository: validDiscovery.canonical_repository,
nodeMap: validDiscovery.server_node_map,
})
})
it('rejects a document that claims write access', () => {
expect(() => parseFifthDomainDiscovery({ ...validDiscovery, access: 'write' }))
.toThrow('public-read-only')
})
it('loads the well-known endpoint without sending credentials', async () => {
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(validDiscovery) })
await fetchFifthDomainDiscovery(fetcher)
expect(fetcher).toHaveBeenCalledWith(FIFTH_DOMAIN_DISCOVERY_URL, {
cache: 'no-store',
credentials: 'omit',
signal: undefined,
})
})
})

View File

@ -0,0 +1,57 @@
export const FIFTH_DOMAIN_DISCOVERY_URL = 'https://guanghulab.com/.well-known/guanghu.json'
export interface FifthDomainDiscovery {
access: 'public-read-only'
canonicalRepository: string
name: string
nodeMap: string
repositoryMap: string
resolveApi: string
schema: 'guanghu.ai-discovery/v1'
searchApi: string
}
type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Pick<Response, 'ok' | 'json'>>
function requiredString(document: Record<string, unknown>, key: string): string {
const value = document[key]
if (typeof value !== 'string' || !value.trim()) throw new Error(`Fifth Domain discovery is missing ${key}.`)
return value
}
function requiredHttpsUrl(document: Record<string, unknown>, key: string): string {
const value = requiredString(document, key)
if (!value.startsWith('https://guanghulab.com/')) throw new Error(`Fifth Domain discovery ${key} is not a trusted HTTPS route.`)
return value
}
export function parseFifthDomainDiscovery(value: unknown): FifthDomainDiscovery {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Fifth Domain discovery is not an object.')
const document = value as Record<string, unknown>
if (document.schema !== 'guanghu.ai-discovery/v1') throw new Error('Fifth Domain discovery schema is unsupported.')
if (document.access !== 'public-read-only') throw new Error('Fifth Domain connection must remain public-read-only.')
return {
access: 'public-read-only',
canonicalRepository: requiredHttpsUrl(document, 'canonical_repository'),
name: requiredString(document, 'name'),
nodeMap: requiredHttpsUrl(document, 'server_node_map'),
repositoryMap: requiredHttpsUrl(document, 'repository_map'),
resolveApi: requiredHttpsUrl(document, 'resolve_api'),
schema: 'guanghu.ai-discovery/v1',
searchApi: requiredHttpsUrl(document, 'search_api'),
}
}
export async function fetchFifthDomainDiscovery(
fetcher: Fetcher = fetch,
signal?: AbortSignal,
): Promise<FifthDomainDiscovery> {
const response = await fetcher(FIFTH_DOMAIN_DISCOVERY_URL, {
cache: 'no-store',
credentials: 'omit',
signal,
})
if (!response.ok) throw new Error('Fifth Domain public route did not respond successfully.')
return parseFifthDomainDiscovery(await response.json())
}

View File

@ -177,10 +177,22 @@ describe('buildContextSnapshot', () => {
it('includes system preamble', () => {
const result = buildContextSnapshot({ activeEntry: active, entries })
expect(result).toContain('AI assistant integrated into Tolaria')
expect(result).toContain('current AI instance operating the Guanghu language-personality system')
expect(result).toContain('Context Snapshot')
})
it('does not instruct a direct API model to invent vault read tools', () => {
const result = buildContextSnapshot({
activeEntry: active,
entries,
capabilities: 'embedded-context-only',
})
expect(result).not.toContain('get_note')
expect(result).not.toContain('read_file')
expect(result).toContain('Only call tools explicitly provided by the API')
})
it('includes vault summary with types and totalNotes', () => {
const result = buildContextSnapshot({ activeEntry: active, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])

View File

@ -80,6 +80,7 @@ export interface ContextSnapshotParams {
noteListFilter?: { type: string | null; query: string }
entries: VaultEntry[]
references?: NoteReference[]
capabilities?: 'vault-tools' | 'embedded-context-only'
}
const MAX_ACTIVE_NOTE_BODY_CHARS = 24_000
@ -134,27 +135,32 @@ function entryFrontmatter(e: VaultEntry): Record<string, unknown> {
return fm
}
function unavailableBodyInstruction(activeEntry: VaultEntry): string {
function unavailableBodyInstruction(activeEntry: VaultEntry, canReadVault: boolean): string {
if (!canReadVault) return `[Content not available in the embedded editor context (${activeEntry.wordCount} words). Ask the user to open or explicitly reference this note if its full content is required.]`
return `[Content not available in editor context — use get_note("${activeEntry.path}") to read the full note (${activeEntry.wordCount} words)]`
}
function truncatedBodyInstruction(path: string, omittedChars: number): string {
function truncatedBodyInstruction(path: string, omittedChars: number, canReadVault: boolean): string {
return [
'[Active note body truncated by Tolaria to keep CLI agent context within provider limits.',
`Omitted approximately ${omittedChars} characters from the middle.`,
`Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]`,
canReadVault
? `Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]`
: 'Ask the user to open or explicitly reference the full note before content-sensitive edits or summaries.]',
].join(' ')
}
function truncatedReferencedBodyInstruction(path: string, omittedChars: number): string {
function truncatedReferencedBodyInstruction(path: string, omittedChars: number, canReadVault: boolean): string {
return [
'[Referenced note body truncated by Tolaria to keep CLI agent context within provider limits.',
`Omitted approximately ${omittedChars} characters from the middle.`,
`Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]`,
canReadVault
? `Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]`
: 'Ask the user to explicitly include the full note before content-sensitive edits or summaries.]',
].join(' ')
}
function compactActiveNoteBody(body: string, path: string): ActiveNoteBody {
function compactActiveNoteBody(body: string, path: string, canReadVault: boolean): ActiveNoteBody {
if (body.length <= MAX_ACTIVE_NOTE_BODY_CHARS) {
return { body }
}
@ -164,7 +170,7 @@ function compactActiveNoteBody(body: string, path: string): ActiveNoteBody {
const omittedChars = Math.max(0, body.length - ACTIVE_NOTE_BODY_HEAD_CHARS - ACTIVE_NOTE_BODY_TAIL_CHARS)
return {
body: `${head}\n\n${truncatedBodyInstruction(path, omittedChars)}\n\n${tail}`,
body: `${head}\n\n${truncatedBodyInstruction(path, omittedChars, canReadVault)}\n\n${tail}`,
bodyTruncated: {
shownChars: ACTIVE_NOTE_BODY_HEAD_CHARS + ACTIVE_NOTE_BODY_TAIL_CHARS,
totalChars: body.length,
@ -173,7 +179,7 @@ function compactActiveNoteBody(body: string, path: string): ActiveNoteBody {
}
}
function compactReferencedNoteBody(body: string, path: string): ActiveNoteBody {
function compactReferencedNoteBody(body: string, path: string, canReadVault: boolean): ActiveNoteBody {
if (body.length <= MAX_REFERENCED_NOTE_BODY_CHARS) {
return { body }
}
@ -183,7 +189,7 @@ function compactReferencedNoteBody(body: string, path: string): ActiveNoteBody {
const omittedChars = Math.max(0, body.length - REFERENCED_NOTE_BODY_HEAD_CHARS - REFERENCED_NOTE_BODY_TAIL_CHARS)
return {
body: `${head}\n\n${truncatedReferencedBodyInstruction(path, omittedChars)}\n\n${tail}`,
body: `${head}\n\n${truncatedReferencedBodyInstruction(path, omittedChars, canReadVault)}\n\n${tail}`,
bodyTruncated: {
shownChars: REFERENCED_NOTE_BODY_HEAD_CHARS + REFERENCED_NOTE_BODY_TAIL_CHARS,
totalChars: body.length,
@ -192,16 +198,16 @@ function compactReferencedNoteBody(body: string, path: string): ActiveNoteBody {
}
}
function activeNoteBody(activeEntry: VaultEntry, activeNoteContent?: string): ActiveNoteBody {
function activeNoteBody(activeEntry: VaultEntry, activeNoteContent: string | undefined, canReadVault: boolean): ActiveNoteBody {
const body = extractBody(activeNoteContent || '')
if (!body && activeEntry.wordCount > 0) {
return { body: unavailableBodyInstruction(activeEntry) }
return { body: unavailableBodyInstruction(activeEntry, canReadVault) }
}
return compactActiveNoteBody(body, activeEntry.path)
return compactActiveNoteBody(body, activeEntry.path, canReadVault)
}
function activeNoteSnapshot(activeEntry: VaultEntry, activeNoteContent?: string): Record<string, unknown> {
const bodySnapshot = activeNoteBody(activeEntry, activeNoteContent)
function activeNoteSnapshot(activeEntry: VaultEntry, activeNoteContent: string | undefined, canReadVault: boolean): Record<string, unknown> {
const bodySnapshot = activeNoteBody(activeEntry, activeNoteContent, canReadVault)
const note: Record<string, unknown> = {
path: activeEntry.path,
title: activeEntry.title,
@ -239,7 +245,7 @@ function hasNoteListFilter(noteListFilter?: { type: string | null; query: string
return Boolean(noteListFilter?.type || noteListFilter?.query)
}
function referencedNoteSnapshot(ref: NoteReference): Record<string, unknown> {
function referencedNoteSnapshot(ref: NoteReference, canReadVault = true): Record<string, unknown> {
const note: Record<string, unknown> = {
path: ref.path,
title: ref.title,
@ -247,22 +253,24 @@ function referencedNoteSnapshot(ref: NoteReference): Record<string, unknown> {
}
if (ref.content === undefined) {
note.body = `[Referenced note content not embedded — use get_note("${ref.path}") to read the full note before answering about it.]`
note.body = canReadVault
? `[Referenced note content not embedded — use get_note("${ref.path}") to read the full note before answering about it.]`
: '[Referenced note content not embedded. Ask the user to explicitly include it before answering about its contents.]'
return note
}
const bodySnapshot = compactReferencedNoteBody(extractBody(ref.content), ref.path)
const bodySnapshot = compactReferencedNoteBody(extractBody(ref.content), ref.path, canReadVault)
note.body = bodySnapshot.body
assignIfPresent(note, 'bodyTruncated', bodySnapshot.bodyTruncated)
return note
}
function referencedNotesSnapshot(references?: NoteReference[]): Record<string, unknown>[] {
return references?.map(referencedNoteSnapshot) ?? []
function referencedNotesSnapshot(references?: NoteReference[], canReadVault = true): Record<string, unknown>[] {
return references?.map(ref => referencedNoteSnapshot(ref, canReadVault)) ?? []
}
function appendReferencedNotes(snapshot: Record<string, unknown>, references?: NoteReference[]): void {
const referencedNotes = referencedNotesSnapshot(references)
function appendReferencedNotes(snapshot: Record<string, unknown>, references: NoteReference[] | undefined, canReadVault: boolean): void {
const referencedNotes = referencedNotesSnapshot(references, canReadVault)
if (!referencedNotes.length) return
snapshot.referencedNotes = referencedNotes
@ -281,15 +289,16 @@ function vaultSummary(entries: VaultEntry[]): Record<string, unknown> {
function contextSnapshot(params: ContextSnapshotParams): Record<string, unknown> {
const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
const canReadVault = params.capabilities !== 'embedded-context-only'
const snapshot: Record<string, unknown> = {
activeNote: activeNoteSnapshot(activeEntry, activeNoteContent),
activeNote: activeNoteSnapshot(activeEntry, activeNoteContent, canReadVault),
}
appendOpenTabs(snapshot, activeEntry, openTabs)
appendNoteList(snapshot, noteList)
if (hasNoteListFilter(noteListFilter)) snapshot.noteListFilter = noteListFilter
snapshot.vault = vaultSummary(entries)
appendReferencedNotes(snapshot, references)
appendReferencedNotes(snapshot, references, canReadVault)
return snapshot
}
@ -298,10 +307,14 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
const snapshot = contextSnapshot(params)
const preamble = [
'You are an AI assistant integrated into Tolaria, a personal knowledge management app.',
'You are the current AI instance operating the Guanghu language-personality system inside HoloLake Era.',
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
'If the body field is empty or truncated, use get_note to read the full note from disk before content-sensitive edits or summaries.',
params.capabilities === 'embedded-context-only'
? 'Use only the embedded context below. Only call tools explicitly provided by the API; never invent unavailable file tools.'
: 'You can also use MCP tools to search, read, create, or edit notes in the vault.',
params.capabilities === 'embedded-context-only'
? 'If content is missing or truncated, say so and ask the user to open or explicitly reference it.'
: 'If the body field is empty or truncated, use get_note to read the full note from disk before content-sensitive edits or summaries.',
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
].join('\n')