mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-10-01 01:38:07 +08:00
* feat(uri)!: reject uid-less current-user shorthand in favor of viking://~ viking://user/<segment> (memories/resources/skills/peers/privacy/sessions without a user id) was ambiguous with a user literally named after the segment, and a user actually named e.g. "memories" was unreachable for USER/ADMIN callers. Now that the viking://~ home alias (#4167) covers the same need unambiguously, the shorthand fails closed at the request boundary instead of expanding: - resolve_current_user_uri raises NamespaceShapeError with a corrective hint naming both viking://~/<rest> and the explicit-uid form. Silently parsing the reserved segment as a peer user id would misdirect reads and writes, so rejection is the only safe removal. - Bare viking://user falls through to the canonical parser and keeps container semantics (a user key listing it sees only its own space). - The self-id escape stays: a caller whose user_id equals a reserved name keeps viking://user/<own-id> as their canonical root. ROOT-role literal parsing and the legacy viking://session alias are unchanged. - AddTargetsConfig normalizes stored legacy config spellings (viking://user/resources|skills) to the viking://~ form at validation so existing ov.conf/user_config deployments keep working; the accepted per-user spelling is now viking://~/resources and viking://~/skills. - usage_reporter keeps canonicalizing the historical shorthand found in old transcripts and additionally recognizes viking://~/memories/. BREAKING CHANGE: requests using the uid-less viking://user/<segment> spelling now fail with 400; use viking://~/<segment> or an explicit viking://user/{user_id}/<segment> URI. * refactor(clients): migrate first-party emitters to the viking://~ home alias Every in-repo client that emitted the removed uid-less current-user shorthand now sends viking://~/... instead: vikingbot fallbacks and default sentinels, the LangChain store/tools defaults, the shared recall-core.mjs (all synced plugin copies), the codex/claude-code/ openclaw/openwebui/dsh/zcode/pi plugin emitters, quick-app examples, Go SDK example, tau2 benchmark targets, and the eval golden dataset. Compat kept where legacy strings live in stored user configs: bot and ov_dream sentinels accept both spellings while emitting only ~, and recall-core still rewrites legacy viking://user/<reserved> config values client-side. langchain_openviking._uri now classifies viking://~ with the explicit-user shape so canonicalized server responses keep matching a ~ root. Plugin READMEs note the server requirement for the alias. * docs: replace current-user shorthand guidance with the viking://~ home alias Rewrite every EN/ZH doc and model-facing prompt that advertised the uid-less viking://user/<segment> spelling: URI concept catalogue, context-types/storage/extraction/retrieval/session/privacy concepts, configuration guide (with the legacy add_targets auto-normalization note), resources/skills/sessions/retrieval/admin API references, FAQ, capability reference, and the openviking-memory / ov-experience-memory / openclaw / ov-resources skills. The stale MCP viking://user/<path> dialect passage in the MCP guide is replaced by ~ guidance, and bare viking://user is documented as the container of user spaces. * test(api): migrate live API session-used tests off the removed shorthand tests/api_test/sessions sent uid-less viking://user/skills/... URIs to record_used, which the request boundary now rejects with 400 (caught by the API & CLI Integration Tests CI job; these tests need a live server and are not part of the local suites). The api_test client authenticates as an admin-role user key, so the viking://~ home alias expands for it. tests/api_test/common/test_edge_cases.py is left as is: it asserts a 400 for a non-resource add target, which still holds.
253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
import { Type } from "typebox";
|
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
import type { OVClient } from "./client.js";
|
|
import type { SyncManager } from "./sync.js";
|
|
|
|
export function registerTools(pi: any, client: OVClient, sync?: SyncManager): void {
|
|
|
|
// --- viking_search ---
|
|
pi.registerTool({
|
|
name: "viking_search",
|
|
label: "Viking Search",
|
|
description: "Semantic search over the OpenViking knowledge base. Returns ranked results with viking:// URIs and abstracts. Use to recall past decisions, user preferences, or project-specific knowledge not in current context.",
|
|
promptSnippet: "Search OpenViking for past decisions, preferences, and project knowledge",
|
|
promptGuidelines: [
|
|
"Use viking_search when you need information from previous sessions not in MEMORY.md.",
|
|
"Use viking_search before making decisions that might conflict with past decisions.",
|
|
],
|
|
parameters: Type.Object({
|
|
query: Type.String({ description: "Search query" }),
|
|
scope: Type.Optional(Type.String({ description: "Viking URI prefix to scope search (e.g., 'viking://~/memories/')" })),
|
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 10)" })),
|
|
}),
|
|
async execute(
|
|
_id: string, params: any, _signal: AbortSignal,
|
|
_onUpdate: any, _ctx: any,
|
|
) {
|
|
if (!client.connected) {
|
|
return { content: [{ type: "text", text: "OpenViking server is not reachable." }] };
|
|
}
|
|
const results = await client.find(params.query, {
|
|
targetUri: params.scope,
|
|
topK: params.limit ?? 10,
|
|
});
|
|
if (results.length === 0) {
|
|
return { content: [{ type: "text", text: "No results found." }] };
|
|
}
|
|
const maxChars = client.cfg.recallMaxContentChars;
|
|
const lines = results.map(r => {
|
|
const abs = r.abstract.length > maxChars
|
|
? r.abstract.slice(0, maxChars) + "..."
|
|
: r.abstract;
|
|
return `[${r.score.toFixed(2)}] ${r.uri}\n ${abs}`; }
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: lines.join("\n\n") }],
|
|
details: { results },
|
|
};
|
|
},
|
|
});
|
|
|
|
// --- viking_read ---
|
|
pi.registerTool({
|
|
name: "viking_read",
|
|
label: "Viking Read",
|
|
description: "Read content at a viking:// URI. Three detail levels: 'abstract' (~100 tokens), 'overview' (~2k tokens), 'full' (complete). Start with abstract, escalate when needed.",
|
|
promptSnippet: "Read OpenViking content at a viking:// URI with tiered detail levels",
|
|
parameters: Type.Object({
|
|
uri: Type.String({ description: "viking:// URI to read" }),
|
|
level: StringEnum(["abstract", "overview", "full"] as const),
|
|
}),
|
|
async execute(
|
|
_id: string, params: any, _signal: AbortSignal,
|
|
_onUpdate: any, _ctx: any,
|
|
) {
|
|
if (!client.connected) {
|
|
return { content: [{ type: "text", text: "OpenViking server is not reachable." }] };
|
|
}
|
|
let content: string | null = null;
|
|
switch (params.level) {
|
|
case "abstract": content = await client.abstract(params.uri); break;
|
|
case "overview": content = await client.overview(params.uri); break;
|
|
case "full": content = await client.readContent(params.uri); break;
|
|
}
|
|
if (!content) {
|
|
return { content: [{ type: "text", text: `No content at ${params.uri}` }] };
|
|
}
|
|
return { content: [{ type: "text", text: content }] };
|
|
},
|
|
});
|
|
|
|
// --- viking_browse ---
|
|
pi.registerTool({
|
|
name: "viking_browse",
|
|
label: "Viking Browse",
|
|
description: "Browse the OpenViking knowledge store like a filesystem. List directory contents or get metadata.",
|
|
promptSnippet: "Browse the viking:// directory tree in OpenViking",
|
|
parameters: Type.Object({
|
|
action: StringEnum(["list", "stat"] as const),
|
|
uri: Type.Optional(Type.String({ description: "viking:// URI (default: 'viking://')" })),
|
|
}),
|
|
async execute(
|
|
_id: string, params: any, _signal: AbortSignal,
|
|
_onUpdate: any, _ctx: any,
|
|
) {
|
|
if (!client.connected) {
|
|
return { content: [{ type: "text", text: "OpenViking server is not reachable." }] };
|
|
}
|
|
const uri = params.uri ?? "viking://";
|
|
if (params.action === "stat") {
|
|
const info = await client.stat(uri);
|
|
if (!info) return { content: [{ type: "text", text: `Not found: ${uri}` }] };
|
|
return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }] };
|
|
}
|
|
// list
|
|
const entries = await client.ls(uri);
|
|
if (entries.length === 0) {
|
|
return { content: [{ type: "text", text: `Empty directory: ${uri}` }] };
|
|
}
|
|
const lines = entries.map(e => `${e.isDir ? "📁" : "📄"} ${e.name}`);
|
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
},
|
|
});
|
|
|
|
// --- viking_remember ---
|
|
pi.registerTool({
|
|
name: "viking_remember",
|
|
label: "Viking Remember",
|
|
description: "Store a fact or memory in OpenViking. Stored as a session message and extracted into long-term memory on commit. Use for important information the agent should remember: preferences, decisions, gotchas, lessons learned.",
|
|
promptSnippet: "Store a fact in OpenViking for cross-session persistence",
|
|
promptGuidelines: [
|
|
"Use viking_remember for facts that should survive across sessions but don't belong in MEMORY.md.",
|
|
"Good for: user preferences, architectural decisions, gotchas, environment details.",
|
|
],
|
|
parameters: Type.Object({
|
|
content: Type.String({ description: "The fact or observation to store" }),
|
|
category: Type.Optional(Type.String({ description: "Category hint: 'preference', 'entity', 'event', 'case', 'pattern'" })),
|
|
}),
|
|
async execute(
|
|
_id: string, params: any, _signal: AbortSignal,
|
|
_onUpdate: any, _ctx: any,
|
|
) {
|
|
if (!client.connected) {
|
|
return { content: [{ type: "text", text: "OpenViking server is not reachable." }] };
|
|
}
|
|
// Store as a tagged message directly in OV — the extractor picks up [Remember — ...] prefix
|
|
const category = params.category ?? "general";
|
|
const tagged = `[Remember — ${category}] ${params.content}`;
|
|
|
|
// Directly add to OV session if available
|
|
let stored = false;
|
|
if (sync?.sessionId) {
|
|
stored = await client.addMessage(sync.sessionId, "user", tagged);
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: stored ? `Remembered in OpenViking: "${params.content}" (${category})` : `Queued for OpenViking: "${params.content}" (${category})` }],
|
|
details: { stored, category, tagged },
|
|
};
|
|
},
|
|
});
|
|
|
|
// --- viking_forget ---
|
|
pi.registerTool({
|
|
name: "viking_forget",
|
|
label: "Viking Forget",
|
|
description: "Delete a memory by URI, or search for a specific memory and remove it. Use to correct outdated or wrong information.",
|
|
promptSnippet: "Delete a memory from OpenViking by URI or query",
|
|
parameters: Type.Object({
|
|
uri: Type.Optional(Type.String({ description: "Exact viking:// URI to delete" })),
|
|
query: Type.Optional(Type.String({ description: "Search query — deletes the strongest match if score > 0.8" })),
|
|
}),
|
|
async execute(
|
|
_id: string, params: any, _signal: AbortSignal,
|
|
_onUpdate: any, _ctx: any,
|
|
) {
|
|
if (!client.connected) {
|
|
return { content: [{ type: "text", text: "OpenViking server is not reachable." }] };
|
|
}
|
|
if (params.uri) {
|
|
const ok = await client.delete(params.uri);
|
|
return {
|
|
content: [{ type: "text", text: ok ? `Deleted: ${params.uri}` : `Failed to delete: ${params.uri}` }],
|
|
};
|
|
}
|
|
if (params.query) {
|
|
const results = await client.find(params.query, { topK: 1 });
|
|
if (results.length > 0 && results[0].score > 0.8) {
|
|
const ok = await client.delete(results[0].uri);
|
|
return {
|
|
content: [{ type: "text", text: ok ? `Deleted: ${results[0].uri}` : `Failed: ${results[0].uri}` }],
|
|
};
|
|
}
|
|
return { content: [{ type: "text", text: "No strong match found (score > 0.8 required)." }] };
|
|
}
|
|
return { content: [{ type: "text", text: "Provide either 'uri' or 'query'." }] };
|
|
},
|
|
});
|
|
|
|
// --- viking_add_resource ---
|
|
pi.registerTool({
|
|
name: "viking_add_resource",
|
|
label: "Viking Add Resource",
|
|
description: "Ingest a URL into OpenViking. The page is auto-processed into L0/L1/L2 tiers and indexed for semantic search. HTTP only — local file paths are not supported by the OV server.",
|
|
promptSnippet: "Ingest a URL into OpenViking for indexed retrieval",
|
|
parameters: Type.Object({
|
|
url: Type.String({ description: "URL to ingest (HTTP only, no file paths)" }),
|
|
reason: Type.Optional(Type.String({ description: "Why this resource is relevant (improves indexing)" })),
|
|
}),
|
|
async execute(
|
|
_id: string, params: any, _signal: AbortSignal,
|
|
_onUpdate: any, _ctx: any,
|
|
) {
|
|
if (!client.connected) {
|
|
return { content: [{ type: "text", text: "OpenViking server is not reachable." }] };
|
|
}
|
|
const result = await client.addResource(params.url);
|
|
if (!result) {
|
|
return { content: [{ type: "text", text: `Failed to ingest: ${params.url}` }] };
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: `Ingested: ${result.root_uri}` }],
|
|
details: result,
|
|
};
|
|
},
|
|
});
|
|
|
|
// --- viking_archive_expand ---
|
|
pi.registerTool({
|
|
name: "viking_archive_expand",
|
|
label: "Viking Archive Expand",
|
|
description: "Expand an archived session back into raw messages. Use when the archive summary is too coarse and you need detailed conversation history.",
|
|
promptSnippet: "Expand an archived session to see raw conversation messages",
|
|
parameters: Type.Object({
|
|
archive_id: Type.Optional(Type.String({ description: "Archive ID to expand" })),
|
|
session_id: Type.Optional(Type.String({ description: "OV session ID to expand" })),
|
|
}),
|
|
async execute(
|
|
_id: string, params: any, _signal: AbortSignal,
|
|
_onUpdate: any, _ctx: any,
|
|
) {
|
|
if (!client.connected) {
|
|
return { content: [{ type: "text", text: "OpenViking server is not reachable." }] };
|
|
}
|
|
const sid = params.session_id ?? params.archive_id;
|
|
if (!sid) {
|
|
return { content: [{ type: "text", text: "Provide session_id or archive_id." }] };
|
|
}
|
|
// Read the session's overview — sessions are at viking://session/{sid}
|
|
const uri = `viking://session/${sid}`;
|
|
const content = await client.overview(uri);
|
|
if (!content) {
|
|
// Try reading the history subdirectory
|
|
const history = await client.overview(`${uri}/history`);
|
|
if (!history) {
|
|
return { content: [{ type: "text", text: `Archive not found: ${sid}` }] };
|
|
}
|
|
return { content: [{ type: "text", text: history }] };
|
|
}
|
|
return { content: [{ type: "text", text: content }] };
|
|
},
|
|
});
|
|
}
|