diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83358b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.DS_Store +data/*.json +!data/.gitkeep diff --git a/AGENT_TASKS.md b/AGENT_TASKS.md index 0712d57..4626484 100644 --- a/AGENT_TASKS.md +++ b/AGENT_TASKS.md @@ -3,39 +3,39 @@ > 目标:将插件拆解为可执行任务(按阶段/优先级)。 ## Phase 0 — 基础准备(P0) -- [ ] 明确插件运行环境/依赖(OpenClaw 版本、Node 版本) -- [ ] 定义最终配置文件格式(schema + permissions + registrars) -- [ ] 统一 ID 规则(org/dept/team/agent) +- [x] 明确插件运行环境/依赖(OpenClaw 版本、Node 版本) +- [x] 定义最终配置文件格式(schema + permissions + registrars) +- [x] 统一 ID 规则(org/dept/team/agent) ## Phase 1 — MVP 核心(P0) ### 数据与存储 -- [ ] 设计数据模型(Org/Dept/Team/Agent/Identity/Supervisor) -- [ ] 实现 in-memory store + JSON 持久化 -- [ ] 定义 CRUD API +- [x] 设计数据模型(Org/Dept/Team/Agent/Identity/Supervisor) +- [x] 实现 in-memory store + JSON 持久化 +- [x] 定义 CRUD API ### 权限系统 -- [ ] 实现权限角色(Org Admin / Dept Admin / Team Lead / Agent) -- [ ] 实现权限校验函数 authorize(action, actor, scope) -- [ ] 实现 registrars 白名单(禁止自注册) +- [x] 实现权限角色(Org Admin / Dept Admin / Team Lead / Agent) +- [x] 实现权限校验函数 authorize(action, actor, scope) +- [x] 实现 registrars 白名单(禁止自注册) ### 工具/API -- [ ] create_department -- [ ] create_team -- [ ] register_agent -- [ ] assign_identity -- [ ] set_supervisor -- [ ] whoami -- [ ] query_agents +- [x] create_department +- [x] create_team +- [x] register_agent +- [x] assign_identity +- [x] set_supervisor +- [x] whoami +- [x] query_agents ### Query DSL -- [ ] filters/op 解析(eq / contains / regex) -- [ ] schema queryable 字段约束 -- [ ] pagination(limit/offset) +- [x] filters/op 解析(eq / contains / regex) +- [x] schema queryable 字段约束 +- [x] pagination(limit/offset) ### Scope Memory -- [ ] scope_memory.put(scopeId, text, metadata) -- [ ] scope_memory.search(scopeId, query, limit) -- [ ] 兼容 memory-lancedb-pro +- [x] scope_memory.put(scopeId, text, metadata) +- [x] scope_memory.search(scopeId, query, limit) +- [x] 兼容 memory-lancedb-pro ## Phase 2 — v1 增强(P1) - [ ] 模糊/正则性能优化(索引/缓存) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c42e012 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Yonexus (MVP foundation) + +OpenClaw plugin foundation for: +- Organization hierarchy (Org/Dept/Team) +- Agent registration + multi-identity +- Supervisor mapping +- Role-based authorization +- Query DSL (`eq | contains | regex`) with schema queryable guard +- Scoped shared memory adapter (compatible with memory tools) + +## Quick start + +```bash +npm install +npm run build +bash scripts/install.sh +``` + +## Current status + +Implemented in this branch: +- Data models + JSON persistence store +- Permission checker `authorize(action, actor, scope)` +- Core APIs: + - `createDepartment` + - `createTeam` + - `registerAgent` + - `assignIdentity` + - `setSupervisor` + - `whoami` + - `queryAgents` +- Query parser/executor with pagination +- Scope memory adapter (`put/search`) + +## Notes + +- Persistence file defaults to `data/org.json`. +- Meta fields are validated against schema; unknown fields are dropped. +- Supervisor relation does not imply permissions. diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e551f7e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,48 @@ +{ + "name": "openclaw-plugin-yonexus", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openclaw-plugin-yonexus", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^22.13.10", + "typescript": "^5.7.3" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..179248f --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "openclaw-plugin-yonexus", + "version": "0.1.0", + "description": "Yonexus OpenClaw plugin: hierarchy, identities, permissions, and scoped memory", + "main": "dist/yonexus/index.js", + "types": "dist/yonexus/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist", + "prepare": "npm run clean && npm run build" + }, + "keywords": ["openclaw", "plugin", "organization", "agents"], + "license": "MIT", + "devDependencies": { + "typescript": "^5.7.3", + "@types/node": "^22.13.10" + } +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..6e4a8b4 --- /dev/null +++ b/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "yonexus", + "version": "0.1.0", + "entry": "dist/yonexus/index.js", + "description": "Organization hierarchy, agent identity management, query DSL, and scope memory for OpenClaw", + "permissions": [ + "memory_store", + "memory_recall" + ], + "config": { + "dataFile": "./data/org.json", + "registrars": [], + "schema": { + "position": { "type": "string", "queryable": true }, + "discord_user_id": { "type": "string", "queryable": true }, + "git_user_name": { "type": "string", "queryable": true }, + "department": { "type": "string", "queryable": false }, + "team": { "type": "string", "queryable": false } + } + } +} diff --git a/scripts/install.sh b/scripts/install.sh index 841e9ff..a9153c6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -8,13 +8,14 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIST_DIR="$ROOT_DIR/dist/yonexus" +cd "$ROOT_DIR" + +if [ ! -d node_modules ]; then + npm install +fi + +npm run build mkdir -p "$DIST_DIR" - -# Build step placeholder (replace with actual build command when ready) -# e.g., npm run build - -# Example: copy plugin manifest and compiled assets into dist/yonexus -# cp -r "$ROOT_DIR/plugin.json" "$DIST_DIR/" -# cp -r "$ROOT_DIR/dist-build/*" "$DIST_DIR/" +cp -f "$ROOT_DIR/plugin.json" "$DIST_DIR/plugin.json" echo "[yonexus] install complete -> $DIST_DIR" diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..566b6f5 --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,18 @@ +import type { StoreState, YonexusSchema } from "../models/types"; + +export const DEFAULT_STATE: StoreState = { + organizations: [], + departments: [], + teams: [], + agents: [], + identities: [], + supervisors: [] +}; + +export const DEFAULT_SCHEMA: YonexusSchema = { + position: { type: "string", queryable: true }, + discord_user_id: { type: "string", queryable: true }, + git_user_name: { type: "string", queryable: true }, + department: { type: "string", queryable: false }, + team: { type: "string", queryable: false } +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..04da636 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,105 @@ +import path from "node:path"; +import { DEFAULT_SCHEMA } from "./config/defaults"; +import type { + Actor, + Agent, + Identity, + QueryInput, + Scope, + StoreState, + YonexusSchema +} from "./models/types"; +import { authorize } from "./permissions/authorize"; +import { JsonStore } from "./store/jsonStore"; +import { queryIdentities } from "./tools/query"; +import { makeId } from "./utils/id"; + +export interface YonexusOptions { + dataFile?: string; + schema?: YonexusSchema; + registrars?: string[]; +} + +export class Yonexus { + private readonly schema: YonexusSchema; + private readonly registrars: Set; + private readonly store: JsonStore; + + constructor(options: YonexusOptions = {}) { + const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json"); + this.store = new JsonStore(dataFile); + this.schema = options.schema ?? DEFAULT_SCHEMA; + this.registrars = new Set(options.registrars ?? []); + } + + createDepartment(actor: Actor, name: string, orgId: string) { + authorize("create_department", actor, { orgId }, this.store); + const dept = { id: makeId("dept", name), name, orgId }; + return this.store.addDepartment(dept); + } + + createTeam(actor: Actor, name: string, deptId: string) { + authorize("create_team", actor, { deptId }, this.store); + if (!this.store.findDepartment(deptId)) throw new Error(`department_not_found: ${deptId}`); + const team = { id: makeId("team", `${deptId}-${name}`), name, deptId }; + return this.store.addTeam(team); + } + + registerAgent(actor: Actor, agentId: string, name: string, roles: Agent["roles"] = ["agent"]) { + if (this.registrars.size > 0 && !this.registrars.has(actor.agentId)) { + authorize("register_agent", actor, {}, this.store); + } + if (this.store.findAgent(agentId)) throw new Error(`agent_exists: ${agentId}`); + return this.store.addAgent({ id: agentId, name, roles }); + } + + assignIdentity(actor: Actor, agentId: string, deptId: string, teamId: string, meta: Record): Identity { + authorize("assign_identity", actor, { deptId, teamId }, this.store); + if (!this.store.findAgent(agentId)) throw new Error(`agent_not_found: ${agentId}`); + if (!this.store.findDepartment(deptId)) throw new Error(`department_not_found: ${deptId}`); + if (!this.store.findTeam(teamId)) throw new Error(`team_not_found: ${teamId}`); + + const validatedMeta: Record = {}; + for (const [field, value] of Object.entries(meta)) { + if (!this.schema[field]) continue; + validatedMeta[field] = String(value); + } + + return this.store.addIdentity({ + id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`), + agentId, + deptId, + teamId, + meta: validatedMeta + }); + } + + setSupervisor(actor: Actor, agentId: string, supervisorId: string, deptId?: string) { + authorize("set_supervisor", actor, { deptId }, this.store); + if (!this.store.findAgent(agentId)) throw new Error(`agent_not_found: ${agentId}`); + if (!this.store.findAgent(supervisorId)) throw new Error(`supervisor_not_found: ${supervisorId}`); + if (agentId === supervisorId) throw new Error("invalid_supervisor: self_reference"); + return this.store.upsertSupervisor({ agentId, supervisorId }); + } + + whoami(agentId: string) { + const agent = this.store.findAgent(agentId); + if (!agent) throw new Error(`agent_not_found: ${agentId}`); + + const identities = this.store.listIdentities().filter((x) => x.agentId === agentId); + const supervisor = this.store.findSupervisor(agentId); + return { agent, identities, supervisor }; + } + + queryAgents(actor: Actor, scope: Scope, query: QueryInput) { + authorize("query_agents", actor, scope, this.store); + const identities = this.store.listIdentities(); + return queryIdentities(identities, query, this.schema); + } + + debugSnapshot(): StoreState { + return this.store.snapshot(); + } +} + +export default Yonexus; diff --git a/src/memory/scopeMemory.ts b/src/memory/scopeMemory.ts new file mode 100644 index 0000000..d21939c --- /dev/null +++ b/src/memory/scopeMemory.ts @@ -0,0 +1,16 @@ +export interface MemoryPort { + store(input: { text: string; scope: string; metadata?: Record }): Promise; + recall(input: { query: string; scope: string; limit?: number }): Promise; +} + +export class ScopeMemory { + constructor(private readonly memory: MemoryPort) {} + + async put(scopeId: string, text: string, metadata?: Record): Promise { + return this.memory.store({ text, scope: scopeId, metadata }); + } + + async search(scopeId: string, query: string, limit = 5): Promise { + return this.memory.recall({ query, scope: scopeId, limit }); + } +} diff --git a/src/models/types.ts b/src/models/types.ts new file mode 100644 index 0000000..154b1cd --- /dev/null +++ b/src/models/types.ts @@ -0,0 +1,89 @@ +export type Role = "org_admin" | "dept_admin" | "team_lead" | "agent"; + +export interface Organization { + id: string; + name: string; +} + +export interface Department { + id: string; + name: string; + orgId: string; +} + +export interface Team { + id: string; + name: string; + deptId: string; +} + +export interface Agent { + id: string; + name: string; + roles: Role[]; +} + +export interface Identity { + id: string; + agentId: string; + deptId: string; + teamId: string; + meta: Record; +} + +export interface Supervisor { + agentId: string; + supervisorId: string; +} + +export interface SchemaField { + type: "string"; + queryable: boolean; +} + +export interface YonexusSchema { + [field: string]: SchemaField; +} + +export interface StoreState { + organizations: Organization[]; + departments: Department[]; + teams: Team[]; + agents: Agent[]; + identities: Identity[]; + supervisors: Supervisor[]; +} + +export interface Actor { + agentId: string; +} + +export type Action = + | "create_department" + | "create_team" + | "register_agent" + | "assign_identity" + | "set_supervisor" + | "query_agents"; + +export interface Scope { + orgId?: string; + deptId?: string; + teamId?: string; +} + +export interface QueryFilter { + field: string; + op: "eq" | "contains" | "regex"; + value: string; +} + +export interface QueryOptions { + limit?: number; + offset?: number; +} + +export interface QueryInput { + filters: QueryFilter[]; + options?: QueryOptions; +} diff --git a/src/permissions/authorize.ts b/src/permissions/authorize.ts new file mode 100644 index 0000000..a0b1918 --- /dev/null +++ b/src/permissions/authorize.ts @@ -0,0 +1,34 @@ +import type { Action, Actor, Scope } from "../models/types"; +import { JsonStore } from "../store/jsonStore"; + +function hasRole(store: JsonStore, actor: Actor, role: string): boolean { + const me = store.findAgent(actor.agentId); + return Boolean(me?.roles.includes(role as never)); +} + +function inDeptScope(scope: Scope): boolean { + return Boolean(scope.deptId); +} + +function inTeamScope(scope: Scope): boolean { + return Boolean(scope.teamId); +} + +export function authorize(action: Action, actor: Actor, scope: Scope, store: JsonStore): void { + const orgAdmin = hasRole(store, actor, "org_admin"); + const deptAdmin = hasRole(store, actor, "dept_admin") && inDeptScope(scope); + const teamLead = hasRole(store, actor, "team_lead") && inTeamScope(scope); + const agent = hasRole(store, actor, "agent"); + + const allowed = + (action === "create_department" && orgAdmin) || + (action === "create_team" && (orgAdmin || deptAdmin)) || + (action === "assign_identity" && (orgAdmin || deptAdmin || teamLead)) || + (action === "register_agent" && (orgAdmin || deptAdmin || teamLead)) || + (action === "set_supervisor" && (orgAdmin || deptAdmin)) || + (action === "query_agents" && (orgAdmin || deptAdmin || teamLead || agent)); + + if (!allowed) { + throw new Error(`permission_denied: ${action}`); + } +} diff --git a/src/store/jsonStore.ts b/src/store/jsonStore.ts new file mode 100644 index 0000000..4462530 --- /dev/null +++ b/src/store/jsonStore.ts @@ -0,0 +1,89 @@ +import { DEFAULT_STATE } from "../config/defaults"; +import type { + Agent, + Department, + Identity, + Organization, + StoreState, + Supervisor, + Team +} from "../models/types"; +import { readJsonFile, writeJsonFile } from "../utils/fs"; + +export class JsonStore { + private state: StoreState; + + constructor(private readonly filePath: string) { + this.state = readJsonFile(filePath, DEFAULT_STATE); + } + + save(): void { + writeJsonFile(this.filePath, this.state); + } + + snapshot(): StoreState { + return JSON.parse(JSON.stringify(this.state)) as StoreState; + } + + addOrganization(org: Organization): Organization { + this.state.organizations.push(org); + this.save(); + return org; + } + + addDepartment(dept: Department): Department { + this.state.departments.push(dept); + this.save(); + return dept; + } + + addTeam(team: Team): Team { + this.state.teams.push(team); + this.save(); + return team; + } + + addAgent(agent: Agent): Agent { + this.state.agents.push(agent); + this.save(); + return agent; + } + + addIdentity(identity: Identity): Identity { + this.state.identities.push(identity); + this.save(); + return identity; + } + + upsertSupervisor(rel: Supervisor): Supervisor { + const idx = this.state.supervisors.findIndex((x) => x.agentId === rel.agentId); + if (idx >= 0) this.state.supervisors[idx] = rel; + else this.state.supervisors.push(rel); + this.save(); + return rel; + } + + findAgent(agentId: string): Agent | undefined { + return this.state.agents.find((a) => a.id === agentId); + } + + findDepartment(deptId: string): Department | undefined { + return this.state.departments.find((d) => d.id === deptId); + } + + findTeam(teamId: string): Team | undefined { + return this.state.teams.find((t) => t.id === teamId); + } + + listAgents(): Agent[] { + return this.state.agents; + } + + listIdentities(): Identity[] { + return this.state.identities; + } + + findSupervisor(agentId: string): Supervisor | undefined { + return this.state.supervisors.find((s) => s.agentId === agentId); + } +} diff --git a/src/tools/query.ts b/src/tools/query.ts new file mode 100644 index 0000000..48e928b --- /dev/null +++ b/src/tools/query.ts @@ -0,0 +1,43 @@ +import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../models/types"; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +function isQueryable(field: string, schema: YonexusSchema): boolean { + return Boolean(schema[field]?.queryable); +} + +function matchFilter(identity: Identity, filter: QueryFilter): boolean { + const raw = identity.meta[filter.field] ?? ""; + + switch (filter.op) { + case "eq": + return raw === filter.value; + case "contains": + return raw.toLowerCase().includes(filter.value.toLowerCase()); + case "regex": { + const re = new RegExp(filter.value); + return re.test(raw); + } + default: + return false; + } +} + +function normalizeOptions(options?: QueryOptions): Required { + const limit = Math.min(Math.max(1, options?.limit ?? DEFAULT_LIMIT), MAX_LIMIT); + const offset = Math.max(0, options?.offset ?? 0); + return { limit, offset }; +} + +export function queryIdentities(identities: Identity[], input: QueryInput, schema: YonexusSchema): Identity[] { + for (const filter of input.filters) { + if (!isQueryable(filter.field, schema)) { + throw new Error(`field_not_queryable: ${filter.field}`); + } + } + + const filtered = identities.filter((identity) => input.filters.every((f) => matchFilter(identity, f))); + const { limit, offset } = normalizeOptions(input.options); + return filtered.slice(offset, offset + limit); +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts new file mode 100644 index 0000000..c015a57 --- /dev/null +++ b/src/utils/fs.ts @@ -0,0 +1,18 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function ensureDirForFile(filePath: string): void { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); +} + +export function readJsonFile(filePath: string, fallback: T): T { + if (!fs.existsSync(filePath)) return fallback; + const raw = fs.readFileSync(filePath, "utf8"); + return JSON.parse(raw) as T; +} + +export function writeJsonFile(filePath: string, data: unknown): void { + ensureDirForFile(filePath); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8"); +} diff --git a/src/utils/id.ts b/src/utils/id.ts new file mode 100644 index 0000000..af3a63d --- /dev/null +++ b/src/utils/id.ts @@ -0,0 +1,10 @@ +const SAFE = /[^a-z0-9]+/g; + +export function slug(input: string): string { + return input.trim().toLowerCase().replace(SAFE, "-").replace(/^-+|-+$/g, ""); +} + +export function makeId(prefix: string, input: string): string { + const s = slug(input); + return `${prefix}:${s || "unknown"}`; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..60ab324 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist/yonexus", + "rootDir": "src", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}