Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .kimi-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "ditto",
"version": "0.3.7",
"author": {
"name": "Omni Aura LLC",
"email": "support@heyditto.ai",
"url": "https://heyditto.ai"
},
"homepage": "https://heyditto.ai",
"repository": "https://github.com/ditto-assistant/ditto-plugins",
"license": "MIT",
"keywords": [
"ditto",
"heyditto",
"memory",
"recall",
"knowledge-graph",
"mcp"
],
"description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Kimi Code.",
"skills": [
"./plugins/ditto/skills"
],
"mcpServers": {
"ditto": {
"transport": "http",
"url": "https://api.heyditto.ai/mcp"
}
},
"interface": {
"displayName": "Ditto",
"shortDescription": "Recall and save your Ditto memories, curate music, and hand off chats.",
"developerName": "Omni Aura LLC",
"websiteURL": "https://heyditto.ai"
}
}
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ host. Every root marketplace lists the same plugins, and
out its `skills` field; `agy plugin validate --strict` needs both.
- rename the MCP server key to `<name>` in `.mcp.json`, `mcp.json`,
`mcp_config.json` and the inline Copilot and Kimi entries.
- Kimi Code installs a GitHub URL only from the repository root, and the root
`.kimi-plugin/plugin.json` serves the `ditto` plugin. Other plugins install in
Kimi from a clone of `plugins/<name>`.
3. **Allow its endpoint.** Add `<name>: <https MCP URL>` to `HOSTED_MCP` in
`scripts/validate_package.py`. Only hosted HTTP servers are allowed: no
commands, headers, env or credentials.
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ own OAuth flow: no API keys, npm helpers, CLI bootstrap or lifecycle hooks.
| Codex | `.agents/plugins/marketplace.json` | `codex plugin marketplace add ditto-assistant/ditto-plugins` then `codex plugin add ditto@ditto-plugins` (or install from `/plugins`) |
| GitHub Copilot CLI / VS Code | `.github/plugin/marketplace.json` | `copilot plugin marketplace add ditto-assistant/ditto-plugins` then `copilot plugin install ditto@ditto-plugins` |
| Antigravity | `plugins/ditto/.antigravity-plugin/` | `agy plugin install https://github.com/ditto-assistant/ditto-plugins`, or `/plugin install ./plugins/ditto` from a clone |
| Kimi Code | `plugins/ditto/.kimi-plugin/` | Local only: install `plugins/ditto` from a clone (installing from the GitHub URL finds no manifest at the repository root) |
| Kimi Code | `.kimi-plugin/plugin.json` (root, points into `plugins/ditto`) | `/plugins install https://github.com/ditto-assistant/ditto-plugins` in Kimi Code, or install `plugins/ditto` from a clone |

### Claude

Expand Down Expand Up @@ -112,9 +112,20 @@ Antigravity treats `.mcp.json` as another host's file and
from `mcp_config.json` today, and from the portable `mcp.json` once its
`enable-agent-plugins-spec` flag is on; both name the same Ditto endpoint.

Kimi Code installs only from a local clone of `plugins/ditto`. Installing from the
GitHub URL looks for a manifest at the repository root, which a multi-plugin
repository does not have. Kimi reads the server from `.kimi-plugin/plugin.json`.
Kimi Code installs straight from GitHub. In a Kimi Code session:

```text
/plugins install https://github.com/ditto-assistant/ditto-plugins
```

Choose **Trust and install**, then `/reload`. `/plugins list` shows Ditto with three
skills and one MCP server, and `/mcp` lists `plugin-ditto:ditto` as needing auth; sign
in with `/mcp-config login plugin-ditto:ditto`. Kimi reads a GitHub URL only from the
repository root, so the root `.kimi-plugin/plugin.json` points its `skills` at
`./plugins/ditto/skills`; it is the plugin's own Kimi manifest with that one path
rebased, and the validator keeps the two identical. To pin a branch or tag, use
`https://github.com/ditto-assistant/ditto-plugins/tree/<ref>`. From a clone, install
`./plugins/ditto` instead.

The bundled skills are the verified loading path. The hosted MCP server also
offers the canonical skill files through `skills/list`, `skills/get` and
Expand Down Expand Up @@ -145,6 +156,7 @@ the host-labelled sections of [`adapters/ditto-memory.md`](adapters/ditto-memory
.grok-plugin/marketplace.json # Grok Build
.agents/plugins/marketplace.json # Codex
.github/plugin/marketplace.json # GitHub Copilot / VS Code
.kimi-plugin/plugin.json # Kimi Code GitHub installs (ditto, skills rebased into plugins/ditto)
adapters/ditto-memory.md # per-host connection guidance (source for the generated skill)
plugins/<name>/
.claude-plugin/plugin.json .cursor-plugin/plugin.json .grok-plugin/plugin.json
Expand Down
2 changes: 1 addition & 1 deletion plugins/ditto/.antigravity-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "ditto",
"displayName": "Ditto",
"version": "0.3.6",
"version": "0.3.7",
"description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Antigravity.",
"logo": "assets/icon.png",
"suggestedPrompts": [
Expand Down
2 changes: 1 addition & 1 deletion plugins/ditto/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
"name": "ditto",
"version": "0.3.6",
"version": "0.3.7",
"description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Claude.",
"author": {
"name": "Omni Aura LLC",
Expand Down
2 changes: 1 addition & 1 deletion plugins/ditto/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ditto",
"version": "0.3.6",
"version": "0.3.7",
"author": {
"name": "Omni Aura LLC",
"email": "support@heyditto.ai",
Expand Down
2 changes: 1 addition & 1 deletion plugins/ditto/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "ditto",
"displayName": "Ditto",
"description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Cursor.",
"version": "0.3.6",
"version": "0.3.7",
"author": {
"name": "Omni Aura LLC",
"email": "support@heyditto.ai"
Expand Down
2 changes: 1 addition & 1 deletion plugins/ditto/.github/plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ditto",
"version": "0.3.6",
"version": "0.3.7",
"displayName": "Ditto",
"description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through GitHub Copilot.",
"author": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/ditto/.grok-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ditto",
"version": "0.3.6",
"version": "0.3.7",
"author": {
"name": "Omni Aura LLC",
"email": "support@heyditto.ai",
Expand Down
2 changes: 1 addition & 1 deletion plugins/ditto/.kimi-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ditto",
"version": "0.3.6",
"version": "0.3.7",
"author": {
"name": "Omni Aura LLC",
"email": "support@heyditto.ai",
Expand Down
4 changes: 2 additions & 2 deletions plugins/ditto/shared-skills.lock.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"schema_version": 1,
"source_repository": "https://github.com/ditto-assistant/ditto-skills",
"source_commit": "9a2a3ee2af084fe2833cec7b093cfd371098f867",
"source_commit": "b65d94a9efaad70f0677550a030bb09ec57c0d91",
"host": "plugins",
"core_sha256": "5912af5483c469ec15db27858122fc9128fc25cd239202c0fdd9727de7dd5f6b",
"adapter_sha256": "7cd895ab3feb0581e7e782de46b8b8220e4281a9ebfe6f6b651753e3a545bac1",
"generator_sha256": "6d23fa054bfa19203783fd7bdf6a6949915a5e62b6c71349af7031d5caae1500",
"generator_sha256": "c3cab1c78bcbd50b81bc80698d87567921326ee9886d220ff61c0d5bd18128fe",
"skill_sha256": "dc4a46fc6de30ceac400ced65b122895738757cdcdaf1be529d63bec165e5835",
"files_sha256": {
"plugins/ditto/skills/curate-music/SKILL.md": "b498f8639aeb64e59f88830ac162a894f9f128b7e1f2db6a7c528092904c1eb4",
Expand Down
44 changes: 36 additions & 8 deletions scripts/shared_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
],
}
MANIFESTS = {host: f"plugins/{DEFAULT_PLUGIN}/{paths[0]}" for host, paths in PLUGIN_MANIFESTS.items()}
# Repository-root host manifests that point into one plugin, for hosts that only
# install from a repository root (Kimi Code reads <repo>/.kimi-plugin/plugin.json
# when given a GitHub URL). A plugin opts in with "root_manifests" in plugins.json;
# they are versioned and bumped with that plugin's own manifests.
ROOT_MANIFESTS = {"plugins": {".kimi-plugin/plugin.json"}}


def digest(value):
Expand Down Expand Up @@ -109,6 +114,13 @@ def plugin_registry(source):
names = catalog_names(source, entry["catalog"])
if adapted is not None and names is not None and adapted not in names:
raise ValueError("Adapted skill must belong to the plugin catalog")
roots = entry.get("root_manifests", [])
allowed = set().union(*ROOT_MANIFESTS.values())
if not isinstance(roots, list) or any(not isinstance(r, str) or r not in allowed for r in roots) or len(set(roots)) != len(roots):
raise ValueError("Invalid plugin registry: root_manifests must list known repository-root host manifests")
claimed = [r for entry in data.values() for r in entry.get("root_manifests", [])]
if len(set(claimed)) != len(claimed):
raise ValueError("Invalid plugin registry: a root manifest can point into only one plugin")
return data


Expand Down Expand Up @@ -161,11 +173,19 @@ def additional_files(source, plugin=DEFAULT_PLUGIN, catalog="memory", adapted=DE
return result


def bump_manifests(repo, host, plugin):
files = [file_at(repo, f"plugins/{plugin}/{rel}") for rel in PLUGIN_MANIFESTS[host]]
if not files[0].exists():
def host_manifests(repo, host, plugin, root_manifests=()):
"""Repository-relative versioned manifests of one plugin that exist in repo."""
if any(r not in ROOT_MANIFESTS.get(host, set()) for r in root_manifests):
raise ValueError("Root manifests are not supported for this host")
paths = [f"plugins/{plugin}/{rel}" for rel in PLUGIN_MANIFESTS[host]] + list(root_manifests)
return [p for p in paths if file_at(repo, p).exists()]


def bump_manifests(repo, host, plugin, root_manifests=()):
if not file_at(repo, f"plugins/{plugin}/{PLUGIN_MANIFESTS[host][0]}").exists():
raise ValueError("Missing primary host manifest")
present = [(f, json.loads(f.read_text())) for f in files if f.exists()]
files = [file_at(repo, p) for p in host_manifests(repo, host, plugin, root_manifests)]
Comment thread
Peyton-Spencer marked this conversation as resolved.
Resolved
present = [(f, json.loads(f.read_text())) for f in files]
if any("version" not in m for _, m in present):
raise ValueError("Every host manifest in one plugin must carry a version before bumping")
versions = {m["version"] for _, m in present}
Expand All @@ -181,7 +201,7 @@ def bump_manifests(repo, host, plugin):
return [f.relative_to(Path(repo).resolve()).as_posix() for f, _ in present]


def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog="memory", adapted=DEFAULT_ADAPTED):
def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog="memory", adapted=DEFAULT_ADAPTED, root_manifests=()):
paths = layout(plugin, adapted)
sha = source_identity(source)
generator = file_at(source, GENERATOR).read_bytes()
Expand Down Expand Up @@ -213,7 +233,7 @@ def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog
return False
output_changed = skill is not None and (not file_at(repo, paths["output"]).exists() or file_at(repo, paths["output"]).read_bytes() != skill)
if bump_version and (output_changed or skill is None and not old or old.get("files_sha256", {}) != extra_hashes):
bump_manifests(repo, host, plugin)
bump_manifests(repo, host, plugin, root_manifests)
# A renamed or dropped adapted skill leaves its previous output behind;
# old locks without the key used the default adapted skill.
if old:
Expand All @@ -236,12 +256,20 @@ def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog
def build_all(source, repo, host, bump_version=False):
"""Build every plugin in the source registry that the distribution hosts."""
changed = {}
for plugin, entry in plugin_registry(source).items():
registry = plugin_registry(source)
if host == "plugins":
# A root manifest no plugin claims would silently keep its old version.
claimed = {r for entry in registry.values() for r in entry.get("root_manifests", [])}
for root in sorted(ROOT_MANIFESTS[host] - claimed):
if file_at(repo, root).exists():
raise ValueError(f"{root} exists but no plugin in scripts/plugins.json lists it under root_manifests")
for plugin, entry in registry.items():
if host != "plugins" and plugin != DEFAULT_PLUGIN:
continue # single-host repositories carry only the original plugin
if not file_at(repo, f"plugins/{plugin}/{PLUGIN_MANIFESTS[host][0]}").exists():
raise ValueError(f"Distribution lacks the {plugin} plugin manifest; scaffold it before generating")
changed[plugin] = build(source, repo, host, bump_version, plugin, entry["catalog"], entry.get("adapted_skill"))
roots = entry.get("root_manifests", []) if host == "plugins" else []
Comment thread
ditto-code[bot] marked this conversation as resolved.
Resolved
changed[plugin] = build(source, repo, host, bump_version, plugin, entry["catalog"], entry.get("adapted_skill"), roots)
return changed


Expand Down
20 changes: 20 additions & 0 deletions scripts/validate_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
"antigravity": ".antigravity-plugin/plugin.json",
"kimi": ".kimi-plugin/plugin.json",
}
# Hosts that install a GitHub URL only from the repository root get a root
# manifest that points into one plugin. Kimi Code reads <repo>/.kimi-plugin/plugin.json.
ROOT_MANIFESTS = {".kimi-plugin/plugin.json": ("kimi", "ditto")}
MCP_FILES = {".mcp.json": "type", "mcp.json": "type", "mcp_config.json": None}
# Antigravity loads a manifest that declares the portable Agent Plugins schema as
# an Agent Plugins package: it then treats .mcp.json and mcp.json as other hosts'
Expand Down Expand Up @@ -114,6 +117,21 @@ def check_manifest(host, manifest, plugin, folder):
raise ValueError(f"{host} manifest must use the shared skills folder")


def check_root_manifest(root, relative, host, plugin):
"""A root manifest is the plugin's own host manifest with paths rebased into plugins/<name>."""
path = root / relative
parts = Path(relative).parts
if any((root / Path(*parts[:i])).is_symlink() for i in range(1, len(parts) + 1)):
raise ValueError("Linked release input")
if not path.is_file():
raise ValueError(f"Missing root {host} manifest {relative}")
manifest = load(path)
Comment thread
Peyton-Spencer marked this conversation as resolved.
Resolved
expected = load(root / "plugins" / plugin / MANIFESTS[host])
expected["skills"] = [f"./plugins/{plugin}/skills"]
if manifest != expected:
raise ValueError(f"Root {relative} must equal plugins/{plugin}/{MANIFESTS[host]} with skills ./plugins/{plugin}/skills")


def validate(root):
root = Path(root)
lists = {}
Expand Down Expand Up @@ -167,6 +185,8 @@ def validate(root):
raise ValueError(f"Unexpected release file: {relative}")
if not any((folder / "skills").glob("*/SKILL.md")):
raise ValueError(f"{plugin} ships no skills")
for relative, (host, plugin) in ROOT_MANIFESTS.items():
check_root_manifest(root, relative, host, plugin)
for name in ("ditto-memory", "curate-music", "handoff"):
if not (root / "plugins/ditto/skills" / name / "SKILL.md").is_file():
raise ValueError(f"Missing skill: {name}")
Expand Down
37 changes: 36 additions & 1 deletion tests/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
for folder in ("plugins", ".claude-plugin", ".cursor-plugin", ".grok-plugin", ".agents", ".github/plugin"):
for folder in ("plugins", ".claude-plugin", ".cursor-plugin", ".grok-plugin", ".agents", ".github/plugin", ".kimi-plugin"):
shutil.copytree(ROOT / folder, self.root / folder)

def edit(self, relative, change):
Expand Down Expand Up @@ -78,6 +78,41 @@ def test_portable_mcp_file_declares_its_schema(self):
with self.assertRaisesRegex(ValueError, "portable Agent Plugins MCP file"):
package.validate(self.root)

def test_root_kimi_manifest_points_into_the_plugin(self):
# Kimi Code installs a GitHub URL from the repository root only (DITTO-1799).
cases = (
(lambda d: d.__setitem__("skills", ["./skills"]), "skills ./plugins/ditto/skills"),
(lambda d: d.__setitem__("version", "9.9.9"), "must equal"),
(lambda d: d["mcpServers"]["ditto"].__setitem__("url", "https://example.com/mcp"), "must equal"),
)
for change, message in cases:
with self.subTest(message=message):
self.setUp()
self.edit(".kimi-plugin/plugin.json", change)
with self.assertRaisesRegex(ValueError, message):
package.validate(self.root)
self.setUp()
(self.root / ".kimi-plugin/plugin.json").unlink()
with self.assertRaisesRegex(ValueError, "Missing root kimi manifest"):
package.validate(self.root)
self.setUp()
linked = self.root / ".kimi-plugin/plugin.json"
target = self.root / "kimi-root.json"
target.write_text(linked.read_text())
linked.unlink()
linked.symlink_to(target)
with self.assertRaisesRegex(ValueError, "Linked release input"):
package.validate(self.root)
self.setUp()
folder = self.root / ".kimi-plugin"
outside = Path(self.tmp.name + "-outside")
shutil.copytree(folder, outside)
self.addCleanup(shutil.rmtree, outside)
shutil.rmtree(folder)
folder.symlink_to(outside, target_is_directory=True)
with self.assertRaisesRegex(ValueError, "Linked release input"):
package.validate(self.root)

def test_other_endpoint_is_rejected(self):
f = self.root / "plugins/ditto/.mcp.json"
f.write_text(f.read_text().replace("api.heyditto.ai", "example.com"))
Expand Down
Loading