Files
OpenViking/examples/pi-coding-agent-extension/tools.ts
T
t0saki a83b81715b feat(uri)!: remove uid-less current-user shorthand in favor of viking://~ (#4196)
* 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.
2026-08-21 19:00:19 +08:00

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 }] };
},
});
}