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
28 changes: 0 additions & 28 deletions .github/workflows/plugin-api-v2.yml

This file was deleted.

57 changes: 57 additions & 0 deletions .github/workflows/plugin-api-v3.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: plugin-api-v3

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: akashic-plugins/plugin-contracts
ref: 4dd69dd621e029e51e99aa428443fa3a4ec1f6cf
path: .plugin-contracts
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Check Plugin API v3
env:
PYTHONPATH: .plugin-contracts
run: python -m akashic_plugin_contracts check plugin.py

composition-parity:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: kachofugetsu09/akashic-agent
ref: 3005f838bcd96e2cbc58616aede46e4f39df4523
path: .akashic-core
- uses: actions/checkout@v4
with:
repository: akashic-plugins/citation
ref: b82453cd1eae71da8d25eb31aada30b01c659b54
path: .citation
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
cache-dependency-path: .akashic-core/requirements.txt
- name: Install pinned Core dependencies
run: python -m pip install -r .akashic-core/requirements.txt pytest pytest-asyncio
- name: Verify Meme v3 behavior
env:
AKASHIC_AGENT_ROOT: .akashic-core
AKASHIC_CITATION_ROOT: .citation
PYTHONPATH: .akashic-core
run: python -m pytest -q tests/
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,23 @@

| 接入方式 | 阶段 |
|---|---|
| `prompt_render_modules()` | `prompt_render.emit` 之后——注入表情包目录说明 |
| `@on_after_reasoning()` | AfterReasoning GATE——解析 meme 标签,附加媒体 |
| v3 `PROMPT_RENDER_EVENT` | 注入表情包目录说明 |
| v3 `AFTER_REASONING_PREPROCESS_EVENT` | 解析 meme 标签,附加媒体 |
| `skill_roots = ("skills",)` | 声明管理 Skill |
| `dashboard_module = "dashboard.py"` | 声明 v3 Dashboard |
| `workspace_roots = ("memes",)` | 取得 Core 分配的表情包资产根 |

插件通过模块命名导出声明静态贡献,通过 `apply(ctx, config)` 自行构建 `MemeCatalog`/`MemeDecorator` 并注册接入点。`citation.protocol` 是硬依赖:Citation 先剥离 cited metadata 并保留 meme tag,Meme 再完成媒体装饰,Citation cleanup 最后清除残留协议标签。Core 只分配生命周期、依赖顺序与 workspace root;Meme 的目录结构、随机选图和 Dashboard 仍由插件拥有。

---

## 运作逻辑

### 1. 初始化(initialize)
### 1. 初始化

从工作区路径(`workspace/memes/`)加载 `manifest.json`,构建 `MemeCatalog` 和 `MemeDecorator` 实例。`MemeCatalog` 按需检测 manifest 的 mtime,变动时自动热重载,不需要重启。

### 2. 注入 catalog(MemePromptModule)
### 2. 注入 catalog

每轮推理前,调用 `catalog.build_prompt_block()` 把启用的表情包类别(名称、描述、别名)拼成文本块,追加到系统 prompt 底部,告知 LLM 可以在回复中嵌入 `<meme:tag>` 标签。如果 catalog 为空则跳过注入。

Expand Down
5 changes: 5 additions & 0 deletions akashic.plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
schema_version = 1
name = "meme"
version = "1.0.0"
api_version = 3
entrypoint = "plugin.py"
8 changes: 4 additions & 4 deletions dashboard.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
from __future__ import annotations

import os
from pathlib import Path
from typing import Any

from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse

from .plugin import _workspace
from agent.plugin_composition import DashboardContext
from .runtime import MemeCatalog

def register(app: FastAPI, plugin_dir: Path, workspace: Path) -> None:
memes_dir = _workspace(plugin_dir, workspace) / "memes"

def register(app: FastAPI, context: DashboardContext) -> None:
memes_dir = context.workspace_root("memes")
catalog = MemeCatalog(memes_dir)

@app.get("/api/dashboard/meme/categories")
Expand Down
130 changes: 53 additions & 77 deletions plugin.py
Original file line number Diff line number Diff line change
@@ -1,89 +1,71 @@
from __future__ import annotations

import re
from pathlib import Path
from typing import Any, cast

from agent.lifecycle.composition import (
AFTER_REASONING_PREPROCESS_EVENT,
PROMPT_RENDER_EVENT,
)
from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx
from agent.plugins import Plugin, on_after_reasoning
from agent.plugin_composition import Context, ServiceKey
from agent.prompting import PromptSectionRender
from .runtime import MemeCatalog, MemeDecorator

_CTX_SLOT = "prompt:ctx"
_MEME_RE = re.compile(
r"(?<!`)<meme:([a-zA-Z0-9_-]+)>(?!`)",
re.IGNORECASE,
)

CITATION_PROTOCOL_SERVICE = ServiceKey[object]("citation.protocol")


class MemePromptModule:
slot = "meme.prompt"
requires = ("prompt_render.emit", "citation.prompt", _CTX_SLOT)
produces = (_CTX_SLOT,)

def __init__(self, plugin: "MemePlugin") -> None:
self._plugin = plugin

async def run(self, frame: Any) -> Any:
ctx = frame.slots.get(_CTX_SLOT)
if not isinstance(ctx, PromptRenderCtx):
return frame
block = self._plugin.catalog.build_prompt_block()
if not block:
return frame
ctx.system_sections_bottom.append(
PromptSectionRender(
name="memes",
content=f"# Memes\n\n{block}",
is_static=False,
)
def append_meme_prompt(ctx: PromptRenderCtx, catalog: MemeCatalog) -> None:
block = catalog.build_prompt_block()
if not block:
return
ctx.system_sections_bottom.append(
PromptSectionRender(
name="memes",
content=f"# Memes\n\n{block}",
is_static=False,
)
return frame


class MemePlugin(Plugin):
api_version = 2
@classmethod
def dashboard_module(cls) -> str | None:
return "dashboard.py"

name = "meme"
version = "1.0.0"

@classmethod
def skill_roots(cls) -> tuple[str, ...]:
return ("skills",)
_catalog: Any = None
_decorator: Any = None

async def prepare(self) -> None:
memes_dir = _workspace(self.context.plugin_dir, self.context.workspace) / "memes"
self._catalog = MemeCatalog(memes_dir)
self._decorator = MemeDecorator(self._catalog)

def prompt_render_modules(self) -> list[object]:
return [MemePromptModule(self)]

@on_after_reasoning()
async def decorate_meme(self, ctx: AfterReasoningCtx) -> AfterReasoningCtx:
cleaned, tag = _extract_meme_tag(ctx.reply)
decorated = self.decorator.decorate(cleaned, meme_tag=tag)
ctx.reply = decorated.content
ctx.media.extend(decorated.media)
ctx.meme_tag = decorated.tag
return ctx

@property
def catalog(self) -> Any:
if self._catalog is None:
raise RuntimeError("meme 插件尚未初始化")
return self._catalog

@property
def decorator(self) -> Any:
if self._decorator is None:
raise RuntimeError("meme 插件尚未初始化")
return self._decorator
)


def decorate_meme_ctx(ctx: AfterReasoningCtx, decorator: MemeDecorator) -> None:
cleaned, tag = _extract_meme_tag(ctx.reply)
decorated = decorator.decorate(cleaned, meme_tag=tag)
ctx.reply = decorated.content
ctx.media.extend(decorated.media)
ctx.meme_tag = decorated.tag


api_version = 3
name = "meme"
version = "1.0.0"
inject: tuple[ServiceKey[object], ...] = (CITATION_PROTOCOL_SERVICE,)
skill_roots = ("skills",)
dashboard_module = "dashboard.py"
workspace_roots = ("memes",)


async def apply(ctx: Context, config: object) -> None:
"""Build Meme domain objects and register their Core-hosted adapters."""

# 1. Domain state remains plugin-owned and reads the assigned workspace.
_ = config
catalog = MemeCatalog(ctx.workspace_root("memes"))
decorator = MemeDecorator(catalog)

# 2. Lifecycle behavior is owned by reversible Fiber effects.
def prompt_listener(prompt: PromptRenderCtx) -> None:
append_meme_prompt(prompt, catalog)

def answer_listener(answer: AfterReasoningCtx) -> None:
decorate_meme_ctx(answer, decorator)

_ = await ctx.on(PROMPT_RENDER_EVENT, prompt_listener)
_ = await ctx.on(AFTER_REASONING_PREPROCESS_EVENT, answer_listener)


def _extract_meme_tag(response: str) -> tuple[str, str | None]:
Expand All @@ -94,9 +76,3 @@ def _extract_meme_tag(response: str) -> tuple[str, str | None]:
cleaned = re.sub(r"[ \t]+\n", "\n", cleaned)
cleaned = re.sub(r" {2,}", " ", cleaned)
return cleaned.strip(), match.group(1).lower()


def _workspace(plugin_dir: Path, configured: Path | None) -> Path:
if configured is not None:
return configured
return cast(Path, plugin_dir.parent.parent)
Loading
Loading