mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-09-28 11:43:00 +08:00
* refactor(skills): install skills through one shared helper POST /api/v1/skills kept its whole install loop (source resolution, per-skill install, source metadata, list_only) inline in the route. Move it into openviking/server/skill_ingest.py:install_skills so the MCP add_skill tool and signed skill uploads can reuse the exact same code path. The REST route's behavior is unchanged. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(mcp): add an add_skill tool MCP clients had no way to create a skill: write refuses the skills/ subtree (_USER_MANAGED_SUBTREES) and add_resource validates its target as a resource. Agents that should keep skills in OpenViking could read them but never add one. add_skill takes either the full SKILL.md text (data) or a path. A Git or GitHub tree URL installs through the same source resolution as REST, with skills=[...] to pick from a multi-skill repository and list_only to preview it. A local SKILL.md, directory, or zip gets the add_resource treatment: the tool mints a one-time upload token, now tagged kind="skill" with the target root, selection and list_only, and the signed temp_upload installs the file as skills instead of ingesting it as a resource. target_uri="viking://agent/skills" shares the skill with the account. All three paths (REST, MCP inline/Git, signed upload) go through skill_ingest.install_skills. The tool count in the server log, app comment, docs, and the Codex plugin's REAL_MCP_TOOLS moves to 16. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(mcp): search shared skills in find(context_type="skill") Without a target_uri, find resolved the generic default targets, which stop at the caller's user root, so a skill search never reached the account-shared viking://agent/skills. REST /skills/find and the context search already cover both roots. When context_type resolves to skill only and no target_uri is given, the MCP tool now targets default_target_directories(ctx, context_type=SKILL): the user's own skills plus viking://agent/skills. REST find semantics are unchanged. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(mcp): print directory abstracts in tree(include_abstract=true) The tree tool skipped to the next entry right after printing a directory, and only printed abstracts for files, but the storage layer only fills abstracts for directories (files always come back empty). The flag therefore never printed anything. Print the abstract after either kind of entry and ask for up to 1024 characters, enough for a full skill description, so tree(uri="viking://~/skills", level_limit=1, include_abstract=true) lists every skill with its description. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(skills): honor node_limit in GET /api/v1/skills list_skills declared node_limit but always listed each skill root with a hardcoded 1000. Pass it through per root; 0 keeps the default so the CLI's accepted range (-n 0) still lists everything. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(mcp): point skill hits in find/search at their SKILL.md A skill is indexed through its directory's .abstract.md, so find and list-mode search printed hits like viking://agent/skills/x/.abstract.md. Following the "use the read tool to expand a URI" advice returned only the frontmatter, and read_content inlined the same stub. Skill hits now show <dir>/SKILL.md, and read_content reads that file. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(mcp): validate add_skill targets and sources before minting an upload Review findings on the add_skill tool: - target_uri passed the content-kind check for any path under a skills root (viking://~/skills/pdf) and, for ROOT, for another user's root, but the installer only accepts the caller's own skills root or viking://agent/skills. On the local-path branch the tool minted a one-time upload token anyway, and the upload failed with 400 after the token was spent. The target is now resolved with the installer's own rule first; shared subpaths map to viking://agent/skills, the rest fail at once, and the error names both allowed roots. - Non-Git remote sources such as tos:// were treated as remote, then refused as "direct host filesystem paths". add_skill now decides Git with the same prefixes resolve_skill_source uses (shared as GIT_SKILL_SOURCE_PREFIXES) and reports other schemes as unsupported. - With list_only, the upload instructions still said the skill would be installed and that no further call was needed; they now say the upload only lists the source's skills. - The zip example packaged hidden files, so .git and .env files went into the stored skill. It now excludes VCS data, .env files, node_modules and .DS_Store, starting from a fresh archive. - tree(include_abstract=true) printed the "abstract is not ready" placeholder for directories that never get an abstract, such as a skill's scripts/. Those placeholders are skipped. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * docs(mcp): say that write only refuses the user's own skills subtree The capability reference claimed MCP write refuses every skill URI. It refuses the user's own skills/ subtree, but under viking://agent/skills it writes a plain file that skips skill installation. State that, and point shared skills at add_skill as well. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * style(skills): format skill_processor.py Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(plugins): inject an <available-skills> catalog at session start Agents on every harness learned about memories at session start but had no idea which skills OpenViking held, so a stored skill was only found if a later recall happened to surface it. buildProfileBlock() now takes the caller's resolved config as a fourth argument and, when skillCatalog is on (default), adds <available-skills> after <available-memories>: one GET /api/v1/skills lists the user's own skills first, then account-shared ones, dropping a shared skill the user shadows by name. Descriptions are cut to about 40 tokens and envelope tags in them are escaped, since the shared root is written by anyone on the account. The block has its own budget (skillCatalogTokenBudget, default 1200) and degrades from descriptions to names to a one-line count; with no skills, or a server without the endpoint, it is omitted. The shared formatListing now gives entries back so its "+N more" tail fits: a greedily filled listing never left room for it, so a cut listing ended silently. This also applies to <available-memories>. All six callers (claude-code, codex, opencode, dsh, pi, and the thin hook runtime for cursor/trae/zcode) pass their config through. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(plugins): recall account-shared skills and flag skill entries The server context face already mixes both skill roots into per-prompt recall, but the plugins' last-resort ranked find only searched viking://~/memories and viking://~/skills, so on servers without the context face a shared skill in viking://agent/skills could never be recalled. Add it as a third source, and name each skill hit by its directory rather than the .abstract.md it was indexed through, matching the context face and the session-start catalog. When an injected recall block carries a skill (a type="skills" entry, or a [skill] line from the fallback), its header gains one line telling the agent to read the skill's SKILL.md before following it. Turns without a skill are unchanged. The openclaw plugin's vendored recall-core copy is regenerated with it. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(plugins): point skill writes at add_skill in the URI guard A local Write or Edit aimed at viking://.../skills/... was denied with a hint to use MCP write or edit instead, but the server refuses both under the skills subtree, so the hint led straight into a second error. Hints may now depend on the URI: for a skill URI (viking://~/skills, viking://user/<id>/skills, viking://agent/skills) the default table and dsh's bridged table name add_skill with an add_skill(data="<the full SKILL.md text>") example. Other URIs keep their hints. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(plugins): bundle an openviking-skills skill Nothing told an agent how to work with skills stored in OpenViking: how to load one from the catalog, run its helper files, create one through add_skill, install from Git or a local folder, share it with the account, or move the user's existing local skills over. examples/skills/openviking-skills covers all of that, including a user-triggered, one-time migration of ~/.claude/skills, ~/.agents/skills and ~/.cursor/skills that keeps environment-bound skills local (shipped by a plugin or marketplace, symlinked in by a CLI installer such as lark-cli, or needing a local binary) and uploads only what the user approves skill by skill. It passes strict server validation. sync.mjs ships it wherever add_skill is a real tool and a bundled skill loads: the codex, claude-code, cursor and dsh plugins. openviking-memory now points to it for skill work. A new sync test keeps synced skills flat, since copySkill copies a flat file list and a subdirectory would crash it; the marketplace tests pin the packaged copies, and dsh's provider test expects both bundled skills. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * chore(plugins): bump versions for the skill integration claude-code 0.6.0, codex 0.10.0, agent-hook (cursor/trae/zcode) 0.4.0 and dsh 0.5.0 gain the skill catalog and, except trae/zcode, the bundled openviking-skills skill; opencode 0.3.3 and pi 0.3.3 gain the catalog. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(codex): recall shared skills and flag skill entries in Codex too Codex builds its recall block itself instead of through recall-core's wrappers, so the previous commit's changes never reached it: its raw search still skipped viking://agent/skills, its digest carried no skill hint, and its post-processing kept only level-2 leaves, which silently dropped every skill hit (skills are found through their directory's level-0 abstract), including the viking://~/skills search it already ran. recall-core now exports skillEntryHint() and skillHitUri(). The hint is added when a block carries a type="skills" entry or cites any skill URI, which also covers digests that only keep URIs. Codex searches the shared skill root as a third bucket, labels skill hits "skills" under their directory URI, lets them through post-processing, and adds the same hint line to its envelope. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(cursor): install openviking-skills next to openviking-memory sync.mjs puts openviking-skills into hosts/cursor/skills, but the installer copied only openviking-memory into ~/.cursor/skills, so Cursor never saw the new skill. Install, uninstall, the post-install check and the doctor's file list now cover both skills. The install test also moves to the agent-hook plugin's new 0.4.0 version string. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(dsh): keep PLUGIN_VERSION in step with package.json The version bump moved package.json to 0.5.0 but left the PLUGIN_VERSION constant at 0.4.3, which bundle.test.mjs and npm run check:version compare against the manifest. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * docs(skills): match ov-skills commands to the real ov CLI flags The ov-skills skill documented flags the CLI never had (--json, and a --limit that is only a hidden alias), a raw-content "ov skills add -" form that sends a literal "-", and ov resources subcommands that do not exist. Every command line now follows the clap definitions: -o json for JSON, -n/--node-limit, -p/--uri on read commands and -p/--parent-auto-create on add, -s/--skill as a comma list, show --format, and validate's --strict-only body-length warning. ov add-skill is documented as the same command as ov skills add. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * docs(plugins): document the skill catalog, openviking-skills, and skill-aware recall The integration pages (Claude Code, Codex, Cursor, TRAE, opencode, pi, dsh), the capability reference, the plugin development guide, and the plugin READMEs now describe the <available-skills> session-start block, its skillCatalog / skillCatalogTokenBudget knobs, the bundled openviking-skills skill where it ships, recall reaching viking://agent/skills with the skill-entry hint, and the URI guard sending skill writes to add_skill. en and zh pages carry the same facts. Stale statements fixed on the lines touched: thin hook hosts use the same 10000-token profile budget as the rest, the claude-code and codex plugins ship four skills, dsh mounts the server MCP surface, and the opencode install guide lists openviking_add_skill once. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(plugins): let the user's own skills use the catalog budget the shared group leaves Review findings on <available-skills>: - Each group got at most its even share of the listing budget, with unused tokens passing only forward, so the user's own skills (always first) never got more than half. Twenty own skills and one shared skill fell back to names only while most of the 1200 tokens went unused. A group now takes its even share or everything the later groups leave when listed in full, whichever is larger. - When a group's share could not hold its header plus the "+N more" tail, formatListing gave back every entry and printed a bare header, which reads as an empty directory. It now prints the one-line "N entries, budget too tight" stub instead (memory listings too). - A budget too small for even the one-line count now injects nothing rather than overrunning it. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(plugins): rank skill hits like memory leaves in the recall fallbacks Naming a skill hit by its directory instead of its .abstract.md cost it the 0.12 leaf boost, since the boost keyed on a ".md" URI, so a skill that main would recall lost to ten slightly weaker memory leaves. In Codex, skills were also never picked while enough memory leaves passed the threshold (leaves are picked first), and a hit the server labeled with another category lost its "skills" label. Skill hits now count as leaves in ranking and picking in both recall-core and Codex, and Codex always labels them "skills". Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(plugins): send shared-skill edits back to the shared root in the URI guard The guard's add_skill example carried no target_uri, and add_skill without one installs into the caller's own root. Fixing a shared skill that way left the team copy unchanged and created a private copy that the catalog then shows instead. For viking://agent/skills URIs the example now passes target_uri="viking://agent/skills", and a helper file (anything below a skill's SKILL.md) points to a folder upload through add_skill(path=...) rather than SKILL.md text. addSkillExample() builds the example for both the default table and dsh's. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * docs(skills): tighten openviking-skills where review found unsafe steps - The catalog is a snapshot that drops descriptions or entries with many skills, so a name missing from it does not prove the name is free. Check <root>/<name>/SKILL.md, and confirm with the user before replacing an existing skill, since add_skill replaces silently. - Updating a shared skill must pass target_uri="viking://agent/skills" after the user confirms; otherwise add_skill creates a private copy that shadows it. - Every file in an uploaded folder is stored with the skill: zip without .git, .env files, node_modules and .DS_Store, and delete the archive afterwards. The migration now inspects the whole folder, hidden files included, for secrets. - Migration flags frontmatter keys OpenViking drops (for example disable-model-invocation or context), and fixes a missing name or description in a temporary copy, never in the user's file. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * feat(plugins): keep the session-start block under the host's inline limit Claude Code saves hook context over 10,000 characters to a file and shows the model a 2 KB preview; Codex and trae-cli spill past about 10,000 bytes; ZCode drops stdout over 32 KB. The session-start block (profile at a 10,000-token budget, memory index, skill catalog, and the archive on resume) routinely ran 25-40 KB, so on these hosts the model saw only the start of the profile, and the catalog appended at the end never reached it. sessionStartMaxBytes caps the whole block in UTF-8 bytes: 9500 for claude-code and codex, 20000 for zcode, no cap elsewhere. Under the cap buildProfileBlock shrinks its token budgets (about 4 bytes per estimated token); if the block still does not fit it drops the memory index, then the catalog. On resume/compact the archive takes up to half, truncated on a line with a pointer to viking://~/sessions/<id>/history/. On resume, claude-code and codex skip the profile block when it matches the one this session already received, since the restored history holds it; a changed block is injected again. Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28 * fix(mcp): return one hit per skill package in find #5045 made a skill index as a whole package, so an item-level find now returns one hit per file inside it. Route skill-only find through SearchService.find_skills, which keeps the best hit per package, and resolve every skill hit to its package's SKILL.md instead of only rewriting the two index sidecars. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(mcp): point skill changes at add_skill in the tool descriptions The server keeps accepting write/edit under viking://agent/skills, and forget still removes a skill directory, so the constraint lives in the tool descriptions: add_skill is the one entry point for creating and updating a skill, and removal goes through ov skills remove or Studio. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * fix(mcp): describe a skill hit by its own abstract A package hit can be any file inside the skill, whose abstract describes that file and not the skill, so find would list a skill under a helper script's summary. Read the package's abstract for those hits, the way GET /skills/find already does. Keep a filter-only skill query on the generic find, which find_skills does not serve. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(mcp): document package-level skill retrieval in find Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * feat(agent-plugins): ship the openviking-skills skill Agent Plugins has no hooks, so no session-start catalog: without this skill the model never learns that the account's skills exist. The skill's own text now reaches for find(context_type="skill") first and treats <available-skills> as something only some harnesses inject, so one copy reads correctly in both kinds of harness. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * fix(plugins): name the skill package a recall hit came from Since a skill is indexed as a whole package, a hit can be any file inside it, not just the two index sidecars the old rewrite stripped. Derive the package root the way the server's skill_root_uri does, drop the internal update backups, and keep one entry per package at its best score. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * fix(plugins): let the skill catalog use both roots' full listings The server already caps each skill root at node_limit, so a second cap over the merged list only bites once the private root alone fills it — and then it drops the shared root whole while reporting nothing dropped. The token budget is what should decide, and it already does. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(plugins): say node_limit caps each skill root, not the merged list Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * fix(mcp): do not paste an unready abstract over a skill hit's own summary fs.abstract returns a placeholder string rather than raising when a package has no usable .abstract.md, so the substitution replaced a useful file summary with a diagnostic line. Reject the same placeholders tree already rejects, and bound the per-package reads the way read_content is bounded. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(mcp): correct how search reports skill hits, and refresh the tool table find and search both render one line per skill package, so the earlier wording — that search returns several hits per package — contradicted the code. Say what actually differs: search still spends a limit slot per matching file and keeps that file's summary. Also point forget and add_resource at add_skill where an agent would look for them, name the REST delete alongside the CLI, and bring the capability table's line citations back in step with mcp_endpoint.py. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(agent-plugins): list add_skill among the tools the package exposes Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * refactor(mcp): drop guards and prose no caller can reach install_skills only ever returns a dict, add_skill always fills root_uri, and a source with no SKILL.md raises before it gets here, so the isinstance, empty-list and missing-uri branches were unreachable. fs.abstract only returns the directory placeholder. One skill package resolves to one rendered item, so the pending map holds one each. In the docstrings, drop what Args already says and the one removal path an MCP caller cannot take. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(plugins): drop a comment about a branch formatListing cannot take A listing left with only its header returns the stub above, so it never reaches the silent close the comment described. Also name sessionStartMaxBytes in buildProfileBlock's options type, where the .d.mts already has it. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * revert(plugins): drop the skill changes in the recall fallback Reverts the recall half of the skill integration:992215bfb,553416200,5e838a0a0and62a456c08. The seven files they touched go back to their state ataa77061c1, the merge base with main. Those four commits taught the local recall assembly about skills: a third source for viking://agent/skills, skillHitUri naming a hit's package, dedupeSkillHits keeping one entry per package, rankItem scoring a skill like a memory leaf, and a header line telling the model to read SKILL.md. Four of the five only ever ran in the raw-find fallback. recallForPeer calls buildServerAssembledBlock first and returns as soon as it answers, so searchAllSources, rankItem and the fallback block builder are reached only on a deployment whose server has no context face. The fifth, the header line in wrapContext, did run on the main path, but the server already reports each entry's type and the URI it wants read, so the line restates what the block carries. Skills still reach the model on the path that runs: the context face searches both skill roots, returns them as entries with type="skills", and the session-start <available-skills> catalog lists every skill with its description. Both stay. The documentation that described the fallback behaviour goes with it. The statements that survive are the ones the context face makes true on its own, such as recall covering the skills shared with the account. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * chore(plugins): bump opencode and pi past main's releases Both were 0.3.3 on this branch and main has since shipped 0.3.3 of its own, so the version a host installs by no longer moves for the skill catalog they now carry through the shared library. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW * docs(mcp): say what list-mode search actually reports for a skill hit The search row claimed a skill package's summary is the matching file's. It is not: _format_search_result rewrites every skill hit onto the package's SKILL.md, keeps the best-scored one per package, and _describe_skills_by_package replaces the summary with the package's own abstract. What is true is that limit applies during retrieval, before that merge, so a package matching several files still spends several slots and fewer than limit results come back. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW
388 lines
14 KiB
JavaScript
388 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { access, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
import { dirname, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
export const SHARED_DIR = join(ROOT, "examples", "memory-plugin-shared", "lib");
|
|
|
|
// What a plugin ships equals what it imports, and neither side is written down.
|
|
// Hand-kept lists were the drift: a group named after one harness got spread
|
|
// into another's, and modules nobody imported ended up vendored into four
|
|
// directories while a module somebody did import went missing and became an
|
|
// ERR_MODULE_NOT_FOUND on the first hook of a fresh install. Each target below
|
|
// only says where its code lives, where its copies go, and whether the copies
|
|
// have to be committed; the file set is the transitive closure of what that
|
|
// code actually imports.
|
|
//
|
|
// `committed` is about how the plugin is delivered, not about taste. A host
|
|
// that installs by pointing at a directory in this repository can only see
|
|
// files git has, so those copies are committed and a bot regenerates them on
|
|
// main. A plugin published as an npm package or assembled into a tarball builds
|
|
// its copies at pack time, so committing them would only tax every review diff.
|
|
export const TARGETS = [
|
|
{
|
|
root: join(ROOT, "examples", "claude-code-memory-plugin"),
|
|
dir: join(ROOT, "examples", "claude-code-memory-plugin", "scripts", "shared"),
|
|
committed: true,
|
|
},
|
|
{
|
|
root: join(ROOT, "examples", "codex-memory-plugin"),
|
|
dir: join(ROOT, "examples", "codex-memory-plugin", "scripts", "shared"),
|
|
committed: true,
|
|
},
|
|
{
|
|
root: join(ROOT, "agent-plugins"),
|
|
dir: join(ROOT, "agent-plugins", "servers", "shared"),
|
|
committed: true,
|
|
},
|
|
{
|
|
root: join(ROOT, "examples", "opencode-plugin"),
|
|
dir: join(ROOT, "examples", "opencode-plugin", "lib", "shared"),
|
|
committed: false,
|
|
},
|
|
{
|
|
root: join(ROOT, "examples", "dsh-memory-plugin"),
|
|
dir: join(ROOT, "examples", "dsh-memory-plugin", "shared"),
|
|
committed: false,
|
|
},
|
|
{
|
|
root: join(ROOT, "examples", "pi-coding-agent-extension"),
|
|
dir: join(ROOT, "examples", "pi-coding-agent-extension", "shared"),
|
|
committed: false,
|
|
},
|
|
// Published as a package too, but ov-install's GitHub source downloads the
|
|
// plugin file by file at a git ref, and it has no way to run this generator.
|
|
{
|
|
root: join(ROOT, "examples", "openclaw-plugin"),
|
|
dir: join(ROOT, "examples", "openclaw-plugin", "shared"),
|
|
committed: true,
|
|
},
|
|
];
|
|
|
|
// cursor, trae, trae-cn and zcode vendor nothing: the installer copies the
|
|
// canonical runtime to `$OV_HOME/agent-integrations/memory-plugin-shared/lib`
|
|
// and they import it by the relative path that resolves both there and here.
|
|
export const ASSEMBLED_ROOTS = [
|
|
join(ROOT, "examples", "agent-hook-plugin"),
|
|
];
|
|
|
|
export const GENERATED_HEADER = "// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.\n";
|
|
|
|
// Skills are copied verbatim — a generated-from banner ahead of the `---`
|
|
// frontmatter would break every skill loader.
|
|
//
|
|
// One entry per copy, the shape TARGETS uses, so the same assertions reach both
|
|
// kinds of generated file. `committed` is true for every skill copy: .gitignore
|
|
// covers the vendored shared/ directories only, and each host installs a skill
|
|
// by copying its path out of this repository, so a copy git does not hold ships
|
|
// nothing. Being a skill under examples/skills is not what ships it — an entry
|
|
// here is.
|
|
export const SKILLS_DIR = join(ROOT, "examples", "skills");
|
|
export const SKILL_TARGETS = [
|
|
// openviking-memory is not shipped to openclaw-plugin: its REST tool surface
|
|
// has its own operator skill (openviking-context-database) with different
|
|
// tool names. Nor to agent-plugins, whose copy of this one skill is a
|
|
// deliberately different hook-free variant.
|
|
{
|
|
skill: "openviking-memory",
|
|
dir: join(ROOT, "examples", "codex-memory-plugin", "skills"),
|
|
committed: true,
|
|
},
|
|
{
|
|
skill: "openviking-memory",
|
|
dir: join(ROOT, "examples", "claude-code-memory-plugin", "skills"),
|
|
committed: true,
|
|
},
|
|
{
|
|
skill: "openviking-memory",
|
|
dir: join(ROOT, "examples", "agent-hook-plugin", "hosts", "cursor", "skills"),
|
|
committed: true,
|
|
},
|
|
{
|
|
skill: "openviking-memory",
|
|
dir: join(ROOT, "examples", "dsh-memory-plugin", "skills"),
|
|
committed: true,
|
|
},
|
|
// The harnesses that bundle skills. agent-plugins has no hooks, so no
|
|
// session-start catalog: there the skill is the only way the model learns
|
|
// that the skills in OpenViking exist.
|
|
{
|
|
skill: "openviking-skills",
|
|
dir: join(ROOT, "examples", "codex-memory-plugin", "skills"),
|
|
committed: true,
|
|
},
|
|
{
|
|
skill: "openviking-skills",
|
|
dir: join(ROOT, "examples", "claude-code-memory-plugin", "skills"),
|
|
committed: true,
|
|
},
|
|
{
|
|
skill: "openviking-skills",
|
|
dir: join(ROOT, "examples", "agent-hook-plugin", "hosts", "cursor", "skills"),
|
|
committed: true,
|
|
},
|
|
{
|
|
skill: "openviking-skills",
|
|
dir: join(ROOT, "examples", "dsh-memory-plugin", "skills"),
|
|
committed: true,
|
|
},
|
|
{
|
|
skill: "openviking-skills",
|
|
dir: join(ROOT, "agent-plugins", "skills"),
|
|
committed: true,
|
|
},
|
|
// The harnesses that ship the experience workflow today. agent-plugins has
|
|
// no hooks and so no session capture: its copy only retrieves and applies
|
|
// Experience, and its reads feed no trajectory back to the server.
|
|
{
|
|
skill: "ov-experience-memory",
|
|
dir: join(ROOT, "examples", "codex-memory-plugin", "skills"),
|
|
committed: true,
|
|
},
|
|
{
|
|
skill: "ov-experience-memory",
|
|
dir: join(ROOT, "examples", "claude-code-memory-plugin", "skills"),
|
|
committed: true,
|
|
},
|
|
{
|
|
skill: "ov-experience-memory",
|
|
dir: join(ROOT, "agent-plugins", "skills"),
|
|
committed: true,
|
|
},
|
|
];
|
|
|
|
const SOURCE_EXTENSIONS = new Set([".mjs", ".js", ".cjs", ".ts", ".mts"]);
|
|
const SKIPPED_DIRS = new Set(["node_modules", ".git", "dist", "coverage"]);
|
|
|
|
const STATIC_IMPORT_RE = /(?:^|[\s;(=])(?:import|export)\b[^;'"]*?from\s*["']([^"']+)["']/g;
|
|
const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
const SIDE_EFFECT_IMPORT_RE = /(?:^|[\s;])import\s*["']([^"']+)["']/g;
|
|
|
|
/** Every module specifier a source file names, in any of the three forms. */
|
|
export function importSpecifiers(source) {
|
|
const found = [];
|
|
for (const re of [STATIC_IMPORT_RE, DYNAMIC_IMPORT_RE, SIDE_EFFECT_IMPORT_RE]) {
|
|
for (const match of source.matchAll(re)) found.push(match[1]);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
async function sourceFilesUnder(dir, skip, out = []) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
return out;
|
|
}
|
|
for (const entry of entries) {
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (SKIPPED_DIRS.has(entry.name) || path === skip) continue;
|
|
await sourceFilesUnder(path, skip, out);
|
|
continue;
|
|
}
|
|
const dot = entry.name.lastIndexOf(".");
|
|
if (dot > 0 && SOURCE_EXTENSIONS.has(entry.name.slice(dot))) out.push(path);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function exists(path) {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The shared modules a target's own code imports directly.
|
|
*
|
|
* Everything under `root` except the vendored directory itself is the target's
|
|
* own code — including re-export shims like claude-code's `scripts/lib/`, which
|
|
* are the only importer of several modules.
|
|
*/
|
|
export async function directSharedImports({ root, dir }) {
|
|
const seeds = new Set();
|
|
const missing = [];
|
|
for (const file of await sourceFilesUnder(root, dir)) {
|
|
const source = await readFile(file, "utf-8");
|
|
for (const spec of importSpecifiers(source)) {
|
|
if (!spec.startsWith(".")) continue;
|
|
const resolved = resolvePath(dirname(file), spec);
|
|
if (!resolved.startsWith(dir + sep)) continue;
|
|
const name = resolved.slice(dir.length + 1);
|
|
seeds.add(name);
|
|
// A module this sync has not generated yet is not missing — it is the
|
|
// reason to run the sync. What is missing is a module that exists
|
|
// neither in lib/ nor beside the copies as a plugin-local file.
|
|
if (!(await exists(join(SHARED_DIR, name))) && !(await exists(resolved))) {
|
|
missing.push({ name, importer: relative(ROOT, file) });
|
|
}
|
|
}
|
|
}
|
|
return { seeds: [...seeds].sort(), missing };
|
|
}
|
|
|
|
/**
|
|
* The transitive closure of `seeds` inside lib/.
|
|
*
|
|
* A seed that lib/ does not have is a plugin-local module living in the same
|
|
* directory (pi's recall-ledger), not an error: it is simply not generated.
|
|
*/
|
|
export async function sharedClosure(seeds) {
|
|
const generated = new Set();
|
|
const pending = [...seeds];
|
|
while (pending.length) {
|
|
const name = pending.pop();
|
|
if (generated.has(name)) continue;
|
|
const source = await readFile(join(SHARED_DIR, name), "utf-8").catch(() => null);
|
|
if (source === null) continue;
|
|
generated.add(name);
|
|
for (const spec of importSpecifiers(source)) {
|
|
if (spec.startsWith("./")) pending.push(spec.slice(2));
|
|
}
|
|
}
|
|
return [...generated].sort();
|
|
}
|
|
|
|
// The installer reads this file instead of computing the closure itself: it
|
|
// runs against a marketplace archive, a flat layout where this generator finds
|
|
// no plugin sources and would silently resolve an empty list.
|
|
export const MANIFEST_PATH = join(SHARED_DIR, "MANIFEST");
|
|
|
|
/** The closure the installer has to assemble for the harnesses that vendor nothing. */
|
|
export async function assembledClosure() {
|
|
const seeds = new Set();
|
|
for (const root of ASSEMBLED_ROOTS) {
|
|
const { seeds: found, missing } = await directSharedImports({ root, dir: SHARED_DIR });
|
|
if (missing.length) {
|
|
const detail = missing.map((m) => `${m.name} (imported by ${m.importer})`).join(", ");
|
|
throw new Error(`the assembled runtime is missing: ${detail}`);
|
|
}
|
|
for (const seed of found) seeds.add(seed);
|
|
}
|
|
return sharedClosure([...seeds]);
|
|
}
|
|
|
|
/** Every target with the file set its own imports resolve to. */
|
|
export async function resolveTargets() {
|
|
const resolved = [];
|
|
for (const target of TARGETS) {
|
|
const { seeds, missing } = await directSharedImports(target);
|
|
if (missing.length) {
|
|
const detail = missing.map((m) => `${m.name} (imported by ${m.importer})`).join(", ");
|
|
throw new Error(`${relative(ROOT, target.dir)}: imports a module that exists nowhere: ${detail}`);
|
|
}
|
|
const own = await sourceFilesUnder(target.root, target.dir);
|
|
resolved.push({
|
|
...target,
|
|
files: await sharedClosure(seeds),
|
|
typed: own.some((file) => file.endsWith(".ts") || file.endsWith(".mts")),
|
|
});
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Copy one module, plus its type declaration for a target written in
|
|
* TypeScript.
|
|
*
|
|
* The `.d.mts` files used to live in the vendored directories, hand-written and
|
|
* hand-kept in step with modules they sat beside — two harnesses had two
|
|
* different, both incomplete, declarations of the same module. They are part of
|
|
* the module now. A JavaScript target has no use for them, so `typed` says
|
|
* whether this one imports from TypeScript.
|
|
*/
|
|
async function copySharedFile(file, targetDir, typed) {
|
|
await mkdir(targetDir, { recursive: true });
|
|
const names = typed ? [file, `${file.slice(0, -4)}.d.mts`] : [file];
|
|
for (const name of names) {
|
|
const body = await readFile(join(SHARED_DIR, name), "utf-8").catch(() => null);
|
|
if (body === null) continue;
|
|
// Written through a rename so a reader never sees half a module: the
|
|
// marketplace staging script runs this generator, and it can run while a
|
|
// test is byte-comparing the copies.
|
|
const target = join(targetDir, name);
|
|
const staging = `${target}.${process.pid}.tmp`;
|
|
await writeFile(staging, `${GENERATED_HEADER}${body}`, "utf-8");
|
|
await rename(staging, target);
|
|
}
|
|
}
|
|
|
|
async function writeManifest(files) {
|
|
const staging = `${MANIFEST_PATH}.${process.pid}.tmp`;
|
|
await writeFile(staging, files.map((file) => `${file}\n`).join(""), "utf-8");
|
|
await rename(staging, MANIFEST_PATH);
|
|
}
|
|
|
|
async function copySkill(skill, targetDir) {
|
|
const sourceDir = join(SKILLS_DIR, skill);
|
|
for (const file of (await readdir(sourceDir)).sort()) {
|
|
const target = join(targetDir, skill);
|
|
await mkdir(target, { recursive: true });
|
|
// Through a rename, for the reason the module copies are: a test can be
|
|
// byte-comparing this file while the staging script runs the generator.
|
|
const path = join(target, file);
|
|
const staging = `${path}.${process.pid}.tmp`;
|
|
await writeFile(staging, await readFile(join(sourceDir, file), "utf-8"), "utf-8");
|
|
await rename(staging, path);
|
|
}
|
|
}
|
|
|
|
/** Vendored copies the target no longer imports; the sync would never touch them again. */
|
|
async function staleCopies(target, keep) {
|
|
const stale = [];
|
|
for (const name of (await readdir(target.dir).catch(() => [])).sort()) {
|
|
const module = name.endsWith(".d.mts") ? `${name.slice(0, -6)}.mjs` : name;
|
|
if (!module.endsWith(".mjs") || keep.includes(module)) continue;
|
|
const body = await readFile(join(target.dir, name), "utf-8");
|
|
if (body.startsWith(GENERATED_HEADER)) stale.push(name);
|
|
}
|
|
return stale;
|
|
}
|
|
|
|
async function main() {
|
|
const claimed = new Set();
|
|
for (const target of await resolveTargets()) {
|
|
for (const file of target.files) {
|
|
await copySharedFile(file, target.dir, target.typed);
|
|
claimed.add(file);
|
|
process.stdout.write(`synced ${file} -> ${relative(ROOT, target.dir)}\n`);
|
|
}
|
|
for (const file of await staleCopies(target, target.files)) {
|
|
process.stdout.write(`stale ${file} in ${relative(ROOT, target.dir)} — nothing imports it; delete it\n`);
|
|
}
|
|
}
|
|
|
|
const assembled = await assembledClosure();
|
|
await writeManifest(assembled);
|
|
process.stdout.write(`wrote ${relative(ROOT, MANIFEST_PATH)}\n`);
|
|
for (const file of assembled) claimed.add(file);
|
|
|
|
const unclaimed = (await readdir(SHARED_DIR))
|
|
.filter((file) => file.endsWith(".mjs") && !claimed.has(file))
|
|
.sort();
|
|
for (const file of unclaimed) {
|
|
process.stdout.write(`unused lib/${file} — no target imports it\n`);
|
|
}
|
|
|
|
for (const { skill, dir } of SKILL_TARGETS) {
|
|
await copySkill(skill, dir);
|
|
process.stdout.write(`synced ${skill}/ -> ${relative(ROOT, dir)}\n`);
|
|
}
|
|
}
|
|
|
|
// Guard the sync behind the entrypoint check so sync.test.mjs can import the
|
|
// target lists as the single source of truth instead of keeping its own copy —
|
|
// the duplicated lists had drifted, and a drifted vendored file passed CI.
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === resolvePath(process.argv[1])) {
|
|
main().catch((err) => {
|
|
process.stderr.write(`${err?.stack || err}\n`);
|
|
process.exit(1);
|
|
});
|
|
}
|