From 1436d63a8c2eee1074cbde38195c85de1222317f Mon Sep 17 00:00:00 2001 From: orion Date: Sat, 7 Mar 2026 05:21:25 +0000 Subject: [PATCH 1/4] feat: scaffold yonexus MVP core with storage, auth, query, and scope memory --- .gitignore | 6 ++ AGENT_TASKS.md | 44 +++++++-------- README.md | 39 +++++++++++++ data/.gitkeep | 0 package-lock.json | 48 ++++++++++++++++ package.json | 18 ++++++ plugin.json | 21 +++++++ scripts/install.sh | 15 ++--- src/config/defaults.ts | 18 ++++++ src/index.ts | 105 +++++++++++++++++++++++++++++++++++ src/memory/scopeMemory.ts | 16 ++++++ src/models/types.ts | 89 +++++++++++++++++++++++++++++ src/permissions/authorize.ts | 34 ++++++++++++ src/store/jsonStore.ts | 89 +++++++++++++++++++++++++++++ src/tools/query.ts | 43 ++++++++++++++ src/utils/fs.ts | 18 ++++++ src/utils/id.ts | 10 ++++ tsconfig.json | 16 ++++++ 18 files changed, 600 insertions(+), 29 deletions(-) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 data/.gitkeep create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 plugin.json create mode 100644 src/config/defaults.ts create mode 100644 src/index.ts create mode 100644 src/memory/scopeMemory.ts create mode 100644 src/models/types.ts create mode 100644 src/permissions/authorize.ts create mode 100644 src/store/jsonStore.ts create mode 100644 src/tools/query.ts create mode 100644 src/utils/fs.ts create mode 100644 src/utils/id.ts create mode 100644 tsconfig.json 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"] +} -- 2.49.1 From 0ede080e85def27500775ed61bcd06f226d12a5a Mon Sep 17 00:00:00 2001 From: orion Date: Sat, 7 Mar 2026 05:35:40 +0000 Subject: [PATCH 2/4] feat: add v1 management APIs, audit logs, and import/export --- AGENT_TASKS.md | 6 +- README.md | 8 +++ src/index.ts | 130 ++++++++++++++++++++++++++++++----- src/models/audit.ts | 10 +++ src/models/errors.ts | 19 +++++ src/permissions/authorize.ts | 7 +- src/store/auditStore.ts | 19 +++++ src/store/jsonStore.ts | 51 ++++++++++++++ src/tools/query.ts | 5 +- 9 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 src/models/audit.ts create mode 100644 src/models/errors.ts create mode 100644 src/store/auditStore.ts diff --git a/AGENT_TASKS.md b/AGENT_TASKS.md index 4626484..79a1f3d 100644 --- a/AGENT_TASKS.md +++ b/AGENT_TASKS.md @@ -39,9 +39,9 @@ ## Phase 2 — v1 增强(P1) - [ ] 模糊/正则性能优化(索引/缓存) -- [ ] 管理命令与校验(重命名/删除/迁移) -- [ ] 完善错误码与审计日志 -- [ ] 增加导入/导出工具 +- [x] 管理命令与校验(重命名/删除/迁移) +- [x] 完善错误码与审计日志 +- [x] 增加导入/导出工具 ## Phase 3 — 体验与文档(P1) - [ ] README(安装/配置/示例) diff --git a/README.md b/README.md index c42e012..8c24554 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ Implemented in this branch: - `queryAgents` - Query parser/executor with pagination - Scope memory adapter (`put/search`) +- Management APIs: + - `renameDepartment` + - `renameTeam` + - `migrateTeam` + - `deleteDepartment` + - `deleteTeam` +- Error code model (`YonexusError`) and audit logs +- Import/export APIs (`importData` / `exportData`) ## Notes diff --git a/src/index.ts b/src/index.ts index 04da636..1db0285 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import path from "node:path"; import { DEFAULT_SCHEMA } from "./config/defaults"; +import type { AuditLogEntry } from "./models/audit"; +import { YonexusError } from "./models/errors"; import type { Actor, Agent, @@ -10,6 +12,7 @@ import type { YonexusSchema } from "./models/types"; import { authorize } from "./permissions/authorize"; +import { AuditStore } from "./store/auditStore"; import { JsonStore } from "./store/jsonStore"; import { queryIdentities } from "./tools/query"; import { makeId } from "./utils/id"; @@ -24,6 +27,7 @@ export class Yonexus { private readonly schema: YonexusSchema; private readonly registrars: Set; private readonly store: JsonStore; + private readonly audit = new AuditStore(); constructor(options: YonexusOptions = {}) { const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json"); @@ -32,32 +36,62 @@ export class Yonexus { this.registrars = new Set(options.registrars ?? []); } + private log(entry: Omit): void { + this.audit.append({ + id: makeId("audit", `${entry.actorId}-${entry.action}-${Date.now()}`), + ts: new Date().toISOString(), + ...entry + }); + } + 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); + try { + authorize("create_department", actor, { orgId }, this.store); + const dept = { id: makeId("dept", name), name, orgId }; + const result = this.store.addDepartment(dept); + this.log({ actorId: actor.agentId, action: "create_department", target: result.id, status: "ok" }); + return result; + } catch (error) { + this.log({ + actorId: actor.agentId, + action: "create_department", + target: name, + status: "error", + message: error instanceof Error ? error.message : String(error) + }); + throw error; + } } 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}`); + if (!this.store.findDepartment(deptId)) { + throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`, { deptId }); + } const team = { id: makeId("team", `${deptId}-${name}`), name, deptId }; - return this.store.addTeam(team); + const result = this.store.addTeam(team); + this.log({ actorId: actor.agentId, action: "create_team", target: result.id, status: "ok" }); + return result; } 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); + throw new YonexusError("REGISTRAR_DENIED", `registrar_denied: ${actor.agentId}`); } - if (this.store.findAgent(agentId)) throw new Error(`agent_exists: ${agentId}`); - return this.store.addAgent({ id: agentId, name, roles }); + authorize("register_agent", actor, {}, this.store); + if (this.store.findAgent(agentId)) { + throw new YonexusError("ALREADY_EXISTS", `agent_exists: ${agentId}`, { agentId }); + } + const result = this.store.addAgent({ id: agentId, name, roles }); + this.log({ actorId: actor.agentId, action: "register_agent", target: result.id, status: "ok" }); + return result; } 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}`); + if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`); + if (!this.store.findDepartment(deptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`); + if (!this.store.findTeam(teamId)) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`); const validatedMeta: Record = {}; for (const [field, value] of Object.entries(meta)) { @@ -65,26 +99,30 @@ export class Yonexus { validatedMeta[field] = String(value); } - return this.store.addIdentity({ + const result = this.store.addIdentity({ id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`), agentId, deptId, teamId, meta: validatedMeta }); + this.log({ actorId: actor.agentId, action: "assign_identity", target: result.id, status: "ok" }); + return result; } 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 }); + if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`); + if (!this.store.findAgent(supervisorId)) throw new YonexusError("NOT_FOUND", `supervisor_not_found: ${supervisorId}`); + if (agentId === supervisorId) throw new YonexusError("INVALID_SUPERVISOR", "invalid_supervisor: self_reference"); + const result = this.store.upsertSupervisor({ agentId, supervisorId }); + this.log({ actorId: actor.agentId, action: "set_supervisor", target: `${agentId}->${supervisorId}`, status: "ok" }); + return result; } whoami(agentId: string) { const agent = this.store.findAgent(agentId); - if (!agent) throw new Error(`agent_not_found: ${agentId}`); + if (!agent) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`); const identities = this.store.listIdentities().filter((x) => x.agentId === agentId); const supervisor = this.store.findSupervisor(agentId); @@ -97,6 +135,64 @@ export class Yonexus { return queryIdentities(identities, query, this.schema); } + renameDepartment(actor: Actor, deptId: string, newName: string) { + authorize("create_department", actor, {}, this.store); + const updated = this.store.renameDepartment(deptId, newName); + if (!updated) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`); + this.log({ actorId: actor.agentId, action: "rename_department", target: deptId, status: "ok" }); + return updated; + } + + renameTeam(actor: Actor, teamId: string, newName: string, deptId?: string) { + authorize("create_team", actor, { deptId }, this.store); + const updated = this.store.renameTeam(teamId, newName); + if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`); + this.log({ actorId: actor.agentId, action: "rename_team", target: teamId, status: "ok" }); + return updated; + } + + migrateTeam(actor: Actor, teamId: string, newDeptId: string) { + authorize("create_team", actor, { deptId: newDeptId }, this.store); + if (!this.store.findDepartment(newDeptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${newDeptId}`); + const updated = this.store.migrateTeam(teamId, newDeptId); + if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`); + this.log({ actorId: actor.agentId, action: "migrate_team", target: `${teamId}->${newDeptId}`, status: "ok" }); + return updated; + } + + deleteDepartment(actor: Actor, deptId: string) { + authorize("create_department", actor, {}, this.store); + const ok = this.store.deleteDepartment(deptId); + if (!ok) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`); + this.log({ actorId: actor.agentId, action: "delete_department", target: deptId, status: "ok" }); + return { ok }; + } + + deleteTeam(actor: Actor, teamId: string, deptId?: string) { + authorize("create_team", actor, { deptId }, this.store); + const ok = this.store.deleteTeam(teamId); + if (!ok) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`); + this.log({ actorId: actor.agentId, action: "delete_team", target: teamId, status: "ok" }); + return { ok }; + } + + exportData(actor: Actor): StoreState { + authorize("query_agents", actor, {}, this.store); + this.log({ actorId: actor.agentId, action: "export_data", status: "ok" }); + return this.store.snapshot(); + } + + importData(actor: Actor, state: StoreState): { ok: true } { + authorize("create_department", actor, {}, this.store); + this.store.replace(state); + this.log({ actorId: actor.agentId, action: "import_data", status: "ok" }); + return { ok: true }; + } + + listAuditLogs(limit = 100, offset = 0): AuditLogEntry[] { + return this.audit.list(limit, offset); + } + debugSnapshot(): StoreState { return this.store.snapshot(); } diff --git a/src/models/audit.ts b/src/models/audit.ts new file mode 100644 index 0000000..7ff07fd --- /dev/null +++ b/src/models/audit.ts @@ -0,0 +1,10 @@ +export interface AuditLogEntry { + id: string; + ts: string; + actorId: string; + action: string; + target?: string; + status: 'ok' | 'error'; + message?: string; + meta?: Record; +} diff --git a/src/models/errors.ts b/src/models/errors.ts new file mode 100644 index 0000000..ddfbf23 --- /dev/null +++ b/src/models/errors.ts @@ -0,0 +1,19 @@ +export type ErrorCode = + | 'PERMISSION_DENIED' + | 'NOT_FOUND' + | 'ALREADY_EXISTS' + | 'VALIDATION_ERROR' + | 'FIELD_NOT_QUERYABLE' + | 'INVALID_SUPERVISOR' + | 'REGISTRAR_DENIED'; + +export class YonexusError extends Error { + constructor( + public readonly code: ErrorCode, + message: string, + public readonly details?: Record + ) { + super(message); + this.name = 'YonexusError'; + } +} diff --git a/src/permissions/authorize.ts b/src/permissions/authorize.ts index a0b1918..61ed8b6 100644 --- a/src/permissions/authorize.ts +++ b/src/permissions/authorize.ts @@ -1,3 +1,4 @@ +import { YonexusError } from '../models/errors'; import type { Action, Actor, Scope } from "../models/types"; import { JsonStore } from "../store/jsonStore"; @@ -29,6 +30,10 @@ export function authorize(action: Action, actor: Actor, scope: Scope, store: Jso (action === "query_agents" && (orgAdmin || deptAdmin || teamLead || agent)); if (!allowed) { - throw new Error(`permission_denied: ${action}`); + throw new YonexusError('PERMISSION_DENIED', `permission_denied: ${action}`, { + action, + actorId: actor.agentId, + scope + }); } } diff --git a/src/store/auditStore.ts b/src/store/auditStore.ts new file mode 100644 index 0000000..907afa2 --- /dev/null +++ b/src/store/auditStore.ts @@ -0,0 +1,19 @@ +import type { AuditLogEntry } from '../models/audit'; + +const MAX_AUDIT = 1000; + +export class AuditStore { + private logs: AuditLogEntry[] = []; + + append(entry: AuditLogEntry): AuditLogEntry { + this.logs.push(entry); + if (this.logs.length > MAX_AUDIT) this.logs.shift(); + return entry; + } + + list(limit = 100, offset = 0): AuditLogEntry[] { + const safeLimit = Math.min(Math.max(1, limit), 500); + const safeOffset = Math.max(0, offset); + return this.logs.slice(safeOffset, safeOffset + safeLimit); + } +} diff --git a/src/store/jsonStore.ts b/src/store/jsonStore.ts index 4462530..a86e259 100644 --- a/src/store/jsonStore.ts +++ b/src/store/jsonStore.ts @@ -25,6 +25,11 @@ export class JsonStore { return JSON.parse(JSON.stringify(this.state)) as StoreState; } + replace(state: StoreState): void { + this.state = JSON.parse(JSON.stringify(state)) as StoreState; + this.save(); + } + addOrganization(org: Organization): Organization { this.state.organizations.push(org); this.save(); @@ -37,12 +42,58 @@ export class JsonStore { return dept; } + renameDepartment(deptId: string, name: string): Department | undefined { + const dept = this.findDepartment(deptId); + if (!dept) return undefined; + dept.name = name; + this.save(); + return dept; + } + + deleteDepartment(deptId: string): boolean { + const before = this.state.departments.length; + this.state.departments = this.state.departments.filter((d) => d.id !== deptId); + this.state.teams = this.state.teams.filter((t) => t.deptId !== deptId); + this.state.identities = this.state.identities.filter((i) => i.deptId !== deptId); + const changed = this.state.departments.length !== before; + if (changed) this.save(); + return changed; + } + addTeam(team: Team): Team { this.state.teams.push(team); this.save(); return team; } + renameTeam(teamId: string, name: string): Team | undefined { + const team = this.findTeam(teamId); + if (!team) return undefined; + team.name = name; + this.save(); + return team; + } + + migrateTeam(teamId: string, newDeptId: string): Team | undefined { + const team = this.findTeam(teamId); + if (!team) return undefined; + team.deptId = newDeptId; + for (const identity of this.state.identities) { + if (identity.teamId === teamId) identity.deptId = newDeptId; + } + this.save(); + return team; + } + + deleteTeam(teamId: string): boolean { + const before = this.state.teams.length; + this.state.teams = this.state.teams.filter((t) => t.id !== teamId); + this.state.identities = this.state.identities.filter((i) => i.teamId !== teamId); + const changed = before !== this.state.teams.length; + if (changed) this.save(); + return changed; + } + addAgent(agent: Agent): Agent { this.state.agents.push(agent); this.save(); diff --git a/src/tools/query.ts b/src/tools/query.ts index 48e928b..6852165 100644 --- a/src/tools/query.ts +++ b/src/tools/query.ts @@ -1,3 +1,4 @@ +import { YonexusError } from '../models/errors'; import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../models/types"; const DEFAULT_LIMIT = 20; @@ -33,7 +34,9 @@ function normalizeOptions(options?: QueryOptions): Required { 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}`); + throw new YonexusError('FIELD_NOT_QUERYABLE', `field_not_queryable: ${filter.field}`, { + field: filter.field + }); } } -- 2.49.1 From 76d6a31d250f71db1de2604c4dc9f9672ab1c4cf Mon Sep 17 00:00:00 2001 From: orion Date: Sat, 7 Mar 2026 07:00:51 +0000 Subject: [PATCH 3/4] feat: optimize query path and add smoke test/demo assets --- AGENT_TASKS.md | 8 +- README.md | 2 + examples/sample-data.json | 44 +++ package-lock.json | 543 ++++++++++++++++++++++++++++++++++++++ package.json | 7 +- scripts/demo.ts | 33 +++ src/index.ts | 7 +- src/tools/query.ts | 35 ++- tests/smoke.ts | 40 +++ 9 files changed, 709 insertions(+), 10 deletions(-) create mode 100644 examples/sample-data.json create mode 100644 scripts/demo.ts create mode 100644 tests/smoke.ts diff --git a/AGENT_TASKS.md b/AGENT_TASKS.md index 79a1f3d..497ac24 100644 --- a/AGENT_TASKS.md +++ b/AGENT_TASKS.md @@ -38,15 +38,15 @@ - [x] 兼容 memory-lancedb-pro ## Phase 2 — v1 增强(P1) -- [ ] 模糊/正则性能优化(索引/缓存) +- [x] 模糊/正则性能优化(索引/缓存) - [x] 管理命令与校验(重命名/删除/迁移) - [x] 完善错误码与审计日志 - [x] 增加导入/导出工具 ## Phase 3 — 体验与文档(P1) -- [ ] README(安装/配置/示例) -- [ ] 示例数据集与演示脚本 -- [ ] 安装脚本完善(build + copy 到 dist/yonexus) +- [x] README(安装/配置/示例) +- [x] 示例数据集与演示脚本 +- [x] 安装脚本完善(build + copy 到 dist/yonexus) ## Risk & Notes - 结构数据不进 memory_store(只做 scope 共享记忆) diff --git a/README.md b/README.md index 8c24554..88ee3de 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ OpenClaw plugin foundation for: npm install npm run build bash scripts/install.sh +npm run test:smoke +npm run demo ``` ## Current status diff --git a/examples/sample-data.json b/examples/sample-data.json new file mode 100644 index 0000000..b5b22ba --- /dev/null +++ b/examples/sample-data.json @@ -0,0 +1,44 @@ +{ + "organizations": [ + { "id": "org:yonexus", "name": "Yonexus" } + ], + "departments": [ + { "id": "dept:platform", "name": "Platform", "orgId": "org:yonexus" }, + { "id": "dept:ai", "name": "AI", "orgId": "org:yonexus" } + ], + "teams": [ + { "id": "team:platform-core", "name": "Core", "deptId": "dept:platform" }, + { "id": "team:ai-agent", "name": "Agent", "deptId": "dept:ai" } + ], + "agents": [ + { "id": "orion", "name": "Orion", "roles": ["org_admin", "agent"] }, + { "id": "hangman", "name": "Hangman", "roles": ["agent"] } + ], + "identities": [ + { + "id": "identity:orion-platform", + "agentId": "orion", + "deptId": "dept:platform", + "teamId": "team:platform-core", + "meta": { + "position": "assistant", + "discord_user_id": "1474088632750047324", + "git_user_name": "orion" + } + }, + { + "id": "identity:hangman-ai", + "agentId": "hangman", + "deptId": "dept:ai", + "teamId": "team:ai-agent", + "meta": { + "position": "owner", + "discord_user_id": "561921120408698910", + "git_user_name": "hangman" + } + } + ], + "supervisors": [ + { "agentId": "orion", "supervisorId": "hangman" } + ] +} diff --git a/package-lock.json b/package-lock.json index e551f7e..6e9980f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,452 @@ "license": "MIT", "devDependencies": { "@types/node": "^22.13.10", + "tsx": "^4.19.2", "typescript": "^5.7.3" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -23,6 +466,106 @@ "undici-types": "~6.21.0" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 179248f..6faa4f5 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,15 @@ "scripts": { "build": "tsc -p tsconfig.json", "clean": "rm -rf dist", - "prepare": "npm run clean && npm run build" + "prepare": "npm run clean && npm run build", + "test:smoke": "tsx tests/smoke.ts", + "demo": "tsx scripts/demo.ts" }, "keywords": ["openclaw", "plugin", "organization", "agents"], "license": "MIT", "devDependencies": { "typescript": "^5.7.3", - "@types/node": "^22.13.10" + "@types/node": "^22.13.10", + "tsx": "^4.19.2" } } diff --git a/scripts/demo.ts b/scripts/demo.ts new file mode 100644 index 0000000..4775146 --- /dev/null +++ b/scripts/demo.ts @@ -0,0 +1,33 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { Yonexus } from '../src/index'; + +const dataFile = path.resolve(process.cwd(), 'data/demo-org.json'); +if (fs.existsSync(dataFile)) fs.unlinkSync(dataFile); + +const yx = new Yonexus({ dataFile, registrars: ['orion'] }); + +yx.registerAgent({ agentId: 'orion' }, 'orion', 'Orion', ['org_admin', 'agent']); +yx.registerAgent({ agentId: 'orion' }, 'hangman', 'Hangman', ['agent']); + +const dept = yx.createDepartment({ agentId: 'orion' }, 'Platform', 'org:yonexus'); +const team = yx.createTeam({ agentId: 'orion' }, 'Core', dept.id); + +yx.assignIdentity({ agentId: 'orion' }, 'orion', dept.id, team.id, { + position: 'assistant', + discord_user_id: '1474088632750047324', + git_user_name: 'orion' +}); + +yx.setSupervisor({ agentId: 'orion' }, 'orion', 'hangman', dept.id); + +const query = yx.queryAgents( + { agentId: 'orion' }, + { deptId: dept.id }, + { + filters: [{ field: 'git_user_name', op: 'eq', value: 'orion' }], + options: { limit: 10, offset: 0 } + } +); + +console.log(JSON.stringify({ dept, team, query, audit: yx.listAuditLogs(20, 0) }, null, 2)); diff --git a/src/index.ts b/src/index.ts index 1db0285..4a8e2ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,7 +78,12 @@ export class Yonexus { if (this.registrars.size > 0 && !this.registrars.has(actor.agentId)) { throw new YonexusError("REGISTRAR_DENIED", `registrar_denied: ${actor.agentId}`); } - authorize("register_agent", actor, {}, this.store); + + const isBootstrap = this.store.listAgents().length === 0 && actor.agentId === agentId; + if (!isBootstrap) { + authorize("register_agent", actor, {}, this.store); + } + if (this.store.findAgent(agentId)) { throw new YonexusError("ALREADY_EXISTS", `agent_exists: ${agentId}`, { agentId }); } diff --git a/src/tools/query.ts b/src/tools/query.ts index 6852165..cf506b6 100644 --- a/src/tools/query.ts +++ b/src/tools/query.ts @@ -4,6 +4,25 @@ import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } f const DEFAULT_LIMIT = 20; const MAX_LIMIT = 100; +const regexCache = new Map(); +const containsCache = new Map(); + +function getRegex(pattern: string): RegExp { + const cached = regexCache.get(pattern); + if (cached) return cached; + const created = new RegExp(pattern); + regexCache.set(pattern, created); + return created; +} + +function normalizeNeedle(value: string): string { + const cached = containsCache.get(value); + if (cached) return cached; + const normalized = value.toLowerCase(); + containsCache.set(value, normalized); + return normalized; +} + function isQueryable(field: string, schema: YonexusSchema): boolean { return Boolean(schema[field]?.queryable); } @@ -15,9 +34,9 @@ function matchFilter(identity: Identity, filter: QueryFilter): boolean { case "eq": return raw === filter.value; case "contains": - return raw.toLowerCase().includes(filter.value.toLowerCase()); + return raw.toLowerCase().includes(normalizeNeedle(filter.value)); case "regex": { - const re = new RegExp(filter.value); + const re = getRegex(filter.value); return re.test(raw); } default: @@ -31,6 +50,15 @@ function normalizeOptions(options?: QueryOptions): Required { return { limit, offset }; } +function sortFilters(filters: QueryFilter[]): QueryFilter[] { + const weight = (f: QueryFilter): number => { + if (f.op === 'eq') return 1; + if (f.op === 'contains') return 2; + return 3; + }; + return [...filters].sort((a, b) => weight(a) - weight(b)); +} + export function queryIdentities(identities: Identity[], input: QueryInput, schema: YonexusSchema): Identity[] { for (const filter of input.filters) { if (!isQueryable(filter.field, schema)) { @@ -40,7 +68,8 @@ export function queryIdentities(identities: Identity[], input: QueryInput, schem } } - const filtered = identities.filter((identity) => input.filters.every((f) => matchFilter(identity, f))); + const orderedFilters = sortFilters(input.filters); + const filtered = identities.filter((identity) => orderedFilters.every((f) => matchFilter(identity, f))); const { limit, offset } = normalizeOptions(input.options); return filtered.slice(offset, offset + limit); } diff --git a/tests/smoke.ts b/tests/smoke.ts new file mode 100644 index 0000000..b1df46a --- /dev/null +++ b/tests/smoke.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert'; +import path from 'node:path'; +import fs from 'node:fs'; +import { Yonexus } from '../src/index'; +import { YonexusError } from '../src/models/errors'; + +const dataFile = path.resolve(process.cwd(), 'data/test-org.json'); +if (fs.existsSync(dataFile)) fs.unlinkSync(dataFile); + +const app = new Yonexus({ dataFile, registrars: ['orion'] }); + +app.registerAgent({ agentId: 'orion' }, 'orion', 'Orion', ['org_admin', 'agent']); +app.registerAgent({ agentId: 'orion' }, 'u1', 'U1', ['agent']); +const dept = app.createDepartment({ agentId: 'orion' }, 'Eng', 'org:yonexus'); +const team = app.createTeam({ agentId: 'orion' }, 'API', dept.id); +app.assignIdentity({ agentId: 'orion' }, 'u1', dept.id, team.id, { + git_user_name: 'u1', + position: 'dev' +}); + +const result = app.queryAgents( + { agentId: 'orion' }, + { deptId: dept.id }, + { filters: [{ field: 'git_user_name', op: 'eq', value: 'u1' }] } +); +assert.equal(result.length, 1); + +let thrown = false; +try { + app.queryAgents( + { agentId: 'orion' }, + { deptId: dept.id }, + { filters: [{ field: 'team', op: 'eq', value: 'API' }] } + ); +} catch (e) { + thrown = e instanceof YonexusError && e.code === 'FIELD_NOT_QUERYABLE'; +} +assert.equal(thrown, true); + +console.log('smoke test passed'); -- 2.49.1 From 34e75694c74672f655d9e5798fe27759e47ffe0b Mon Sep 17 00:00:00 2001 From: orion Date: Sat, 7 Mar 2026 07:34:41 +0000 Subject: [PATCH 4/4] docs: add bilingual README with top language navigation --- README.md | 124 +++++++++++++++++++++++++++++++++++++-------------- README.zh.md | 107 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 33 deletions(-) create mode 100644 README.zh.md diff --git a/README.md b/README.md index 88ee3de..844edf4 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,54 @@ -# Yonexus (MVP foundation) +[English](./README.md) | [中文](./README.zh.md) -OpenClaw plugin foundation for: -- Organization hierarchy (Org/Dept/Team) -- Agent registration + multi-identity -- Supervisor mapping +--- + +# Yonexus + +Yonexus is an OpenClaw plugin for organization hierarchy and agent identity management. + +## Features + +- Organization hierarchy: `Organization -> Department -> Team -> Agent` +- Agent registration and multi-identity assignment +- Supervisor relationship mapping (does **not** imply permissions) - Role-based authorization -- Query DSL (`eq | contains | regex`) with schema queryable guard -- Scoped shared memory adapter (compatible with memory tools) +- Query DSL: `eq | contains | regex` +- Queryable field whitelist via schema (`queryable: true`) +- Scope shared memory adapter (`org/dept/team`) +- JSON persistence for structure data +- Audit logs and structured error codes +- Import / export support -## Quick start +## Project Layout + +```text +. +├─ plugin.json +├─ src/ +│ ├─ index.ts +│ ├─ models/ +│ ├─ permissions/ +│ ├─ store/ +│ ├─ tools/ +│ ├─ memory/ +│ └─ utils/ +├─ scripts/ +│ ├─ install.sh +│ └─ demo.ts +├─ tests/ +│ └─ smoke.ts +├─ examples/ +│ └─ sample-data.json +└─ dist/ + └─ yonexus/ +``` + +## Requirements + +- Node.js 22+ +- npm 10+ + +## Quick Start ```bash npm install @@ -18,32 +58,50 @@ npm run test:smoke npm run demo ``` -## Current status +## Configuration -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`) -- Management APIs: - - `renameDepartment` - - `renameTeam` - - `migrateTeam` - - `deleteDepartment` - - `deleteTeam` -- Error code model (`YonexusError`) and audit logs -- Import/export APIs (`importData` / `exportData`) +`plugin.json` includes default config: + +- `name`: `yonexus` +- `entry`: `dist/yonexus/index.js` +- `config.dataFile`: `./data/org.json` +- `config.registrars`: whitelist for registrar agents +- `config.schema`: metadata field schema and queryability + +## Implemented APIs + +Core: +- `createDepartment(actor, name, orgId)` +- `createTeam(actor, name, deptId)` +- `registerAgent(actor, agentId, name, roles?)` +- `assignIdentity(actor, agentId, deptId, teamId, meta)` +- `setSupervisor(actor, agentId, supervisorId, deptId?)` +- `whoami(agentId)` +- `queryAgents(actor, scope, query)` + +Management: +- `renameDepartment(actor, deptId, newName)` +- `renameTeam(actor, teamId, newName, deptId?)` +- `migrateTeam(actor, teamId, newDeptId)` +- `deleteDepartment(actor, deptId)` +- `deleteTeam(actor, teamId, deptId?)` + +Data & audit: +- `exportData(actor)` +- `importData(actor, state)` +- `listAuditLogs(limit?, offset?)` + +## Testing + +Smoke test: + +```bash +npm run test:smoke +``` ## Notes -- Persistence file defaults to `data/org.json`. -- Meta fields are validated against schema; unknown fields are dropped. -- Supervisor relation does not imply permissions. +- Structure data is persisted in JSON, not memory_store. +- Shared scope memory is handled via the scope memory adapter. +- Unknown metadata fields are dropped during identity assignment. +- `queryAgents` enforces schema queryable constraints. diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..bdda11f --- /dev/null +++ b/README.zh.md @@ -0,0 +1,107 @@ +[English](./README.md) | [中文](./README.zh.md) + +--- + +# Yonexus + +Yonexus 是一个用于 OpenClaw 的组织结构与 Agent 身份管理插件。 + +## 功能特性 + +- 组织层级:`Organization -> Department -> Team -> Agent` +- Agent 注册与多身份(Identity)管理 +- 上下级关系(Supervisor,**不自动赋权**) +- 基于角色的权限控制 +- Query DSL:`eq | contains | regex` +- 基于 schema 的可查询字段白名单(`queryable: true`) +- scope 共享记忆适配(org/dept/team) +- 结构化数据 JSON 持久化 +- 审计日志与结构化错误码 +- 导入 / 导出能力 + +## 项目结构 + +```text +. +├─ plugin.json +├─ src/ +│ ├─ index.ts +│ ├─ models/ +│ ├─ permissions/ +│ ├─ store/ +│ ├─ tools/ +│ ├─ memory/ +│ └─ utils/ +├─ scripts/ +│ ├─ install.sh +│ └─ demo.ts +├─ tests/ +│ └─ smoke.ts +├─ examples/ +│ └─ sample-data.json +└─ dist/ + └─ yonexus/ +``` + +## 环境要求 + +- Node.js 22+ +- npm 10+ + +## 快速开始 + +```bash +npm install +npm run build +bash scripts/install.sh +npm run test:smoke +npm run demo +``` + +## 配置说明 + +`plugin.json` 默认包含以下配置: + +- `name`: `yonexus` +- `entry`: `dist/yonexus/index.js` +- `config.dataFile`: `./data/org.json` +- `config.registrars`: 注册人白名单 +- `config.schema`: 元数据字段定义与可查询性 + +## 已实现 API + +核心 API: +- `createDepartment(actor, name, orgId)` +- `createTeam(actor, name, deptId)` +- `registerAgent(actor, agentId, name, roles?)` +- `assignIdentity(actor, agentId, deptId, teamId, meta)` +- `setSupervisor(actor, agentId, supervisorId, deptId?)` +- `whoami(agentId)` +- `queryAgents(actor, scope, query)` + +管理 API: +- `renameDepartment(actor, deptId, newName)` +- `renameTeam(actor, teamId, newName, deptId?)` +- `migrateTeam(actor, teamId, newDeptId)` +- `deleteDepartment(actor, deptId)` +- `deleteTeam(actor, teamId, deptId?)` + +数据与审计: +- `exportData(actor)` +- `importData(actor, state)` +- `listAuditLogs(limit?, offset?)` + +## 测试 + +冒烟测试: + +```bash +npm run test:smoke +``` + +## 说明 + +- 结构数据保存在 JSON 文件,不进入 memory_store。 +- 共享记忆通过 scope memory 适配器处理。 +- 分配 identity 时,未知 meta 字段会被丢弃。 +- `queryAgents` 会严格校验字段是否在 schema 中标记为可查询。 -- 2.49.1