diff --git a/FEAT.md b/FEAT.md new file mode 100644 index 0000000..dc6c33f --- /dev/null +++ b/FEAT.md @@ -0,0 +1,86 @@ +# FEAT — Yonexus Feature List + +## Existing Features + +### Core Model & Storage +- Organization / Department / Team / Agent / Identity / Supervisor data model +- In-memory runtime with JSON persistence (`data/org.json`) +- Import/export of structure data + +### Authorization +- Role model: `org_admin`, `dept_admin`, `team_lead`, `agent` +- `authorize(action, actor, scope)` permission check +- Registrar whitelist (`registrars`) and bootstrap registration support + +### Core APIs +- `createOrganization(actor, name)` +- `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)` + +### Query & Search +- Query ops: `eq`, `contains`, `regex` +- Schema `queryable` whitelist enforcement +- Pagination (`limit`, `offset`) +- Basic query performance optimization (regex cache + ordered filter eval) + +### Management & Audit +- `renameDepartment`, `renameTeam`, `migrateTeam`, `deleteDepartment`, `deleteTeam` +- Structured errors via `YonexusError` +- In-memory audit log (`listAuditLogs`) + +### Scope Memory +- Scope memory adapter: + - `scope_memory.put(scopeId, text, metadata)` + - `scope_memory.search(scopeId, query, limit)` + +### Developer Experience +- `README.md` + `README.zh.md` +- Example data (`examples/sample-data.json`) +- Demo script (`scripts/demo.ts`) +- Smoke test (`tests/smoke.ts`) + +--- + +## New Features (from NEW_FEAT) + +### 1) Filesystem Resource Layout +Data-only filesystem tree under: +- `${openclaw dir}/yonexus/organizations//...` + +Auto-create (idempotent): +- On `createOrganization`: + - `teams/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/` +- On `createTeam`: + - `teams//agents/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/` +- On `assignIdentity`: + - `teams//agents//docs|notes|knowledge|rules|lessons|workflows` + +### 2) Document Query Tool +New API: +- `getDocs(scope, topic, keyword)` + +Parameters: +- `scope`: `organization | department | team | agent` +- `topic`: `docs | notes | knowledge | rules | lessons | workflows` +- `keyword`: regex string + +Behavior: +- Read-only search by filename regex under filesystem resources +- Structured output: + - `----ORG` + - `----DEPT` + - `----TEAM` + - `----AGENT` +- Invalid regex returns structured error (`YonexusError: VALIDATION_ERROR`) + +## Notes +- `${openclaw dir}` resolution order: + 1. `YonexusOptions.openclawDir` + 2. `OPENCLAW_DIR` env + 3. `${HOME}/.openclaw` +- Plugin code is not written into `${openclaw dir}/yonexus`; only data folders/files are used there. diff --git a/NEW_FEAT.md b/NEW_FEAT.md deleted file mode 100644 index b20057f..0000000 --- a/NEW_FEAT.md +++ /dev/null @@ -1,83 +0,0 @@ -# NEW_FEAT — Filesystem Resource Layout - -## Summary -Introduce filesystem-backed resource folders under `${openclaw dir}/yonexus` (data-only, no plugin code). These resources are created automatically when orgs/teams/identities are created. - -## Root -- `${openclaw dir}/yonexus/organizations//` - -### Organization folders -Create on **create_organization**: -``` -teams/ -docs/ -notes/ -knowledge/ -rules/ -lessons/ -workflows/ -``` - -### Team folders -Create on **create_team**: -``` -${openclaw dir}/yonexus/organizations//teams// - agents/ - docs/ - notes/ - knowledge/ - rules/ - lessons/ - workflows/ -``` - -### Agent folders -Create on **assign_identity**: -``` -${openclaw dir}/yonexus/organizations//teams//agents// - docs/ - notes/ - knowledge/ - rules/ - lessons/ - workflows/ -``` - -## Notes -- The plugin must not store code in `${openclaw dir}/yonexus`, only data. -- If folders already exist, creation should be idempotent. - ---- - -# NEW_FEAT — Document Query Tool - -## Summary -Add a tool to retrieve documentation files based on scope, topic, and keyword. - -## Tool -`get_docs(scope, topic, keyword)` - -### Parameters -- **scope**: enum `organization | department | team | agent` -- **topic**: enum `docs | notes | knowledge | rules | lessons | workflows` -- **keyword**: string (supports regular expressions) - -## Behavior -- Search matching files under `${openclaw dir}/yonexus/organizations/...` based on the given scope/topic. -- `keyword` is applied to filenames (regex supported). -- Output grouped by scope in the following format: -``` -----ORG - -----DEPT - -----TEAM - -----AGENT - -``` -- If no match in a group, output the header with no files beneath. - -## Notes -- Search should be read-only. -- Pattern errors should return a structured error (invalid regex). diff --git a/README.md b/README.md index 844edf4..985a071 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Yonexus is an OpenClaw plugin for organization hierarchy and agent identity mana ## Features - Organization hierarchy: `Organization -> Department -> Team -> Agent` +- Filesystem-backed resource layout under `${openclaw dir}/yonexus` - Agent registration and multi-identity assignment - Supervisor relationship mapping (does **not** imply permissions) - Role-based authorization @@ -71,6 +72,7 @@ npm run demo ## Implemented APIs Core: +- `createOrganization(actor, name)` - `createDepartment(actor, name, orgId)` - `createTeam(actor, name, deptId)` - `registerAgent(actor, agentId, name, roles?)` @@ -86,6 +88,9 @@ Management: - `deleteDepartment(actor, deptId)` - `deleteTeam(actor, teamId, deptId?)` +Docs: +- `getDocs(scope, topic, keyword)` + Data & audit: - `exportData(actor)` - `importData(actor, state)` diff --git a/README.zh.md b/README.zh.md index bdda11f..116b412 100644 --- a/README.zh.md +++ b/README.zh.md @@ -9,6 +9,7 @@ Yonexus 是一个用于 OpenClaw 的组织结构与 Agent 身份管理插件。 ## 功能特性 - 组织层级:`Organization -> Department -> Team -> Agent` +- 基于文件系统的资源目录:`${openclaw dir}/yonexus` - Agent 注册与多身份(Identity)管理 - 上下级关系(Supervisor,**不自动赋权**) - 基于角色的权限控制 @@ -71,6 +72,7 @@ npm run demo ## 已实现 API 核心 API: +- `createOrganization(actor, name)` - `createDepartment(actor, name, orgId)` - `createTeam(actor, name, deptId)` - `registerAgent(actor, agentId, name, roles?)` @@ -86,6 +88,9 @@ npm run demo - `deleteDepartment(actor, deptId)` - `deleteTeam(actor, teamId, deptId?)` +文档检索: +- `getDocs(scope, topic, keyword)` + 数据与审计: - `exportData(actor)` - `importData(actor, state)` diff --git a/scripts/demo.ts b/scripts/demo.ts index 4775146..917d9d8 100644 --- a/scripts/demo.ts +++ b/scripts/demo.ts @@ -10,7 +10,8 @@ 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 org = yx.createOrganization({ agentId: 'orion' }, 'Yonexus'); +const dept = yx.createDepartment({ agentId: 'orion' }, 'Platform', org.id); const team = yx.createTeam({ agentId: 'orion' }, 'Core', dept.id); yx.assignIdentity({ agentId: 'orion' }, 'orion', dept.id, team.id, { diff --git a/src/index.ts b/src/index.ts index 4a8e2ed..468a331 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import { YonexusError } from "./models/errors"; import type { Actor, Agent, + DocsScope, + DocsTopic, Identity, QueryInput, Scope, @@ -15,12 +17,14 @@ import { authorize } from "./permissions/authorize"; import { AuditStore } from "./store/auditStore"; import { JsonStore } from "./store/jsonStore"; import { queryIdentities } from "./tools/query"; +import { ResourceLayout } from "./tools/resources"; import { makeId } from "./utils/id"; export interface YonexusOptions { dataFile?: string; schema?: YonexusSchema; registrars?: string[]; + openclawDir?: string; } export class Yonexus { @@ -28,12 +32,19 @@ export class Yonexus { private readonly registrars: Set; private readonly store: JsonStore; private readonly audit = new AuditStore(); + private readonly resources: ResourceLayout; 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 ?? []); + + const openclawDir = + options.openclawDir ?? + process.env.OPENCLAW_DIR ?? + path.resolve(process.env.HOME ?? process.cwd(), ".openclaw"); + this.resources = new ResourceLayout(path.join(openclawDir, "yonexus")); } private log(entry: Omit): void { @@ -44,9 +55,24 @@ export class Yonexus { }); } + createOrganization(actor: Actor, name: string) { + authorize("create_organization", actor, {}, this.store); + const orgId = makeId("org", name); + if (this.store.findOrganization(orgId)) { + throw new YonexusError("ALREADY_EXISTS", `organization_exists: ${orgId}`); + } + const org = this.store.addOrganization({ id: orgId, name }); + this.resources.ensureOrganization(name); + this.log({ actorId: actor.agentId, action: "create_organization", target: org.id, status: "ok" }); + return org; + } + createDepartment(actor: Actor, name: string, orgId: string) { try { authorize("create_department", actor, { orgId }, this.store); + if (!this.store.findOrganization(orgId)) { + throw new YonexusError("NOT_FOUND", `organization_not_found: ${orgId}`, { orgId }); + } 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" }); @@ -65,11 +91,15 @@ export class Yonexus { createTeam(actor: Actor, name: string, deptId: string) { authorize("create_team", actor, { deptId }, this.store); - if (!this.store.findDepartment(deptId)) { - throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`, { deptId }); - } + const dept = this.store.findDepartment(deptId); + if (!dept) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`, { deptId }); + + const org = this.store.findOrganization(dept.orgId); + if (!org) throw new YonexusError("NOT_FOUND", `organization_not_found: ${dept.orgId}`); + const team = { id: makeId("team", `${deptId}-${name}`), name, deptId }; const result = this.store.addTeam(team); + this.resources.ensureTeam(org.name, name); this.log({ actorId: actor.agentId, action: "create_team", target: result.id, status: "ok" }); return result; } @@ -80,9 +110,7 @@ export class Yonexus { } const isBootstrap = this.store.listAgents().length === 0 && actor.agentId === agentId; - if (!isBootstrap) { - authorize("register_agent", actor, {}, this.store); - } + if (!isBootstrap) authorize("register_agent", actor, {}, this.store); if (this.store.findAgent(agentId)) { throw new YonexusError("ALREADY_EXISTS", `agent_exists: ${agentId}`, { agentId }); @@ -95,8 +123,15 @@ export class Yonexus { 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 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 dept = this.store.findDepartment(deptId); + if (!dept) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`); + + const team = this.store.findTeam(teamId); + if (!team) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`); + + const org = this.store.findOrganization(dept.orgId); + if (!org) throw new YonexusError("NOT_FOUND", `organization_not_found: ${dept.orgId}`); const validatedMeta: Record = {}; for (const [field, value] of Object.entries(meta)) { @@ -111,6 +146,8 @@ export class Yonexus { teamId, meta: validatedMeta }); + + this.resources.ensureAgent(org.name, team.name, agentId); this.log({ actorId: actor.agentId, action: "assign_identity", target: result.id, status: "ok" }); return result; } @@ -140,6 +177,10 @@ export class Yonexus { return queryIdentities(identities, query, this.schema); } + getDocs(scope: DocsScope, topic: DocsTopic, keyword: string): string { + return this.resources.getDocs(scope, topic, keyword); + } + renameDepartment(actor: Actor, deptId: string, newName: string) { authorize("create_department", actor, {}, this.store); const updated = this.store.renameDepartment(deptId, newName); diff --git a/src/models/types.ts b/src/models/types.ts index 154b1cd..32de91f 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -59,6 +59,7 @@ export interface Actor { } export type Action = + | "create_organization" | "create_department" | "create_team" | "register_agent" @@ -66,6 +67,9 @@ export type Action = | "set_supervisor" | "query_agents"; +export type DocsScope = "organization" | "department" | "team" | "agent"; +export type DocsTopic = "docs" | "notes" | "knowledge" | "rules" | "lessons" | "workflows"; + export interface Scope { orgId?: string; deptId?: string; diff --git a/src/permissions/authorize.ts b/src/permissions/authorize.ts index 61ed8b6..7547d5e 100644 --- a/src/permissions/authorize.ts +++ b/src/permissions/authorize.ts @@ -22,6 +22,7 @@ export function authorize(action: Action, actor: Actor, scope: Scope, store: Jso const agent = hasRole(store, actor, "agent"); const allowed = + (action === "create_organization" && orgAdmin) || (action === "create_department" && orgAdmin) || (action === "create_team" && (orgAdmin || deptAdmin)) || (action === "assign_identity" && (orgAdmin || deptAdmin || teamLead)) || diff --git a/src/store/jsonStore.ts b/src/store/jsonStore.ts index a86e259..67400bf 100644 --- a/src/store/jsonStore.ts +++ b/src/store/jsonStore.ts @@ -118,14 +118,30 @@ export class JsonStore { return this.state.agents.find((a) => a.id === agentId); } + findOrganization(orgId: string): Organization | undefined { + return this.state.organizations.find((o) => o.id === orgId); + } + + listOrganizations(): Organization[] { + return this.state.organizations; + } + findDepartment(deptId: string): Department | undefined { return this.state.departments.find((d) => d.id === deptId); } + listDepartments(): Department[] { + return this.state.departments; + } + findTeam(teamId: string): Team | undefined { return this.state.teams.find((t) => t.id === teamId); } + listTeams(): Team[] { + return this.state.teams; + } + listAgents(): Agent[] { return this.state.agents; } diff --git a/src/tools/resources.ts b/src/tools/resources.ts new file mode 100644 index 0000000..8b0f194 --- /dev/null +++ b/src/tools/resources.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { YonexusError } from '../models/errors'; +import type { DocsScope, DocsTopic } from '../models/types'; +import { slug } from '../utils/id'; + +const TOPICS: DocsTopic[] = ['docs', 'notes', 'knowledge', 'rules', 'lessons', 'workflows']; + +function ensureDirs(base: string, dirs: string[]): void { + for (const d of dirs) fs.mkdirSync(path.join(base, d), { recursive: true }); +} + +export class ResourceLayout { + constructor(private readonly rootDir: string) {} + + get organizationsRoot(): string { + return path.join(this.rootDir, 'organizations'); + } + + orgPath(orgName: string): string { + return path.join(this.organizationsRoot, slug(orgName)); + } + + teamPath(orgName: string, teamName: string): string { + return path.join(this.orgPath(orgName), 'teams', slug(teamName)); + } + + agentPath(orgName: string, teamName: string, agentId: string): string { + return path.join(this.teamPath(orgName, teamName), 'agents', slug(agentId)); + } + + ensureOrganization(orgName: string): void { + const root = this.orgPath(orgName); + ensureDirs(root, ['teams', ...TOPICS]); + } + + ensureTeam(orgName: string, teamName: string): void { + const root = this.teamPath(orgName, teamName); + ensureDirs(root, ['agents', ...TOPICS]); + } + + ensureAgent(orgName: string, teamName: string, agentId: string): void { + const root = this.agentPath(orgName, teamName, agentId); + ensureDirs(root, TOPICS); + } + + private readTopicFiles(topicRoot: string, keyword: string): string[] { + if (!fs.existsSync(topicRoot)) return []; + let re: RegExp; + try { + re = new RegExp(keyword); + } catch { + throw new YonexusError('VALIDATION_ERROR', 'invalid_regex', { keyword }); + } + + const entries = fs.readdirSync(topicRoot, { withFileTypes: true }); + return entries + .filter((e) => e.isFile() && re.test(e.name)) + .map((e) => path.join(topicRoot, e.name)); + } + + getDocs(scope: DocsScope, topic: DocsTopic, keyword: string): string { + const groups: Record<'ORG' | 'DEPT' | 'TEAM' | 'AGENT', string[]> = { + ORG: [], + DEPT: [], + TEAM: [], + AGENT: [] + }; + + const orgsRoot = this.organizationsRoot; + if (!fs.existsSync(orgsRoot)) { + return '----ORG\n----DEPT\n----TEAM\n----AGENT'; + } + + const orgs = fs.readdirSync(orgsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); + + for (const orgName of orgs) { + const orgPath = path.join(orgsRoot, orgName); + if (scope === 'organization') { + groups.ORG.push(...this.readTopicFiles(path.join(orgPath, topic), keyword)); + } + + const teamsRoot = path.join(orgPath, 'teams'); + if (!fs.existsSync(teamsRoot)) continue; + + const teams = fs.readdirSync(teamsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); + for (const teamName of teams) { + const teamPath = path.join(teamsRoot, teamName); + if (scope === 'team') { + groups.TEAM.push(...this.readTopicFiles(path.join(teamPath, topic), keyword)); + } + + const agentsRoot = path.join(teamPath, 'agents'); + if (!fs.existsSync(agentsRoot)) continue; + + const agents = fs.readdirSync(agentsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); + for (const agentId of agents) { + if (scope === 'agent') { + groups.AGENT.push(...this.readTopicFiles(path.join(agentsRoot, agentId, topic), keyword)); + } + } + } + } + + // department folders are not defined in this layout; reserved empty group for compatible output. + const printGroup = (k: 'ORG' | 'DEPT' | 'TEAM' | 'AGENT'): string => + [`----${k}`, ...groups[k]].join('\n'); + + return [printGroup('ORG'), printGroup('DEPT'), printGroup('TEAM'), printGroup('AGENT')].join('\n'); + } +} diff --git a/tests/smoke.ts b/tests/smoke.ts index b1df46a..d500d58 100644 --- a/tests/smoke.ts +++ b/tests/smoke.ts @@ -4,14 +4,17 @@ import fs from 'node:fs'; import { Yonexus } from '../src/index'; import { YonexusError } from '../src/models/errors'; +const root = path.resolve(process.cwd(), 'data/test-openclaw'); const dataFile = path.resolve(process.cwd(), 'data/test-org.json'); if (fs.existsSync(dataFile)) fs.unlinkSync(dataFile); +if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); -const app = new Yonexus({ dataFile, registrars: ['orion'] }); +const app = new Yonexus({ dataFile, registrars: ['orion'], openclawDir: root }); 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 org = app.createOrganization({ agentId: 'orion' }, 'Yonexus'); +const dept = app.createDepartment({ agentId: 'orion' }, 'Eng', org.id); const team = app.createTeam({ agentId: 'orion' }, 'API', dept.id); app.assignIdentity({ agentId: 'orion' }, 'u1', dept.id, team.id, { git_user_name: 'u1', @@ -25,6 +28,9 @@ const result = app.queryAgents( ); assert.equal(result.length, 1); +const expectedAgentDocsDir = path.join(root, 'yonexus', 'organizations', 'yonexus', 'teams', 'api', 'agents', 'u1', 'docs'); +assert.equal(fs.existsSync(expectedAgentDocsDir), true); + let thrown = false; try { app.queryAgents( @@ -37,4 +43,12 @@ try { } assert.equal(thrown, true); +let invalidRegexThrown = false; +try { + app.getDocs('agent', 'docs', '['); +} catch (e) { + invalidRegexThrown = e instanceof YonexusError && e.code === 'VALIDATION_ERROR'; +} +assert.equal(invalidRegexThrown, true); + console.log('smoke test passed');