refactor: restructure project layout and add install.mjs

- Move src/ → plugin/ with subdirectories:
  - plugin/core/ (business logic, models, store, permissions, utils, memory)
  - plugin/tools/ (query, resources)
  - plugin/commands/ (placeholder for slash commands)
  - plugin/hooks/ (placeholder for lifecycle hooks)
  - plugin/index.ts (wiring layer only, no business logic)
- Add install.mjs with --install, --uninstall, --openclaw-profile-path
- Add skills/ and docs/ root directories
- Move planning docs (PLAN.md, FEAT.md, AGENT_TASKS.md) to docs/
- Remove old scripts/install.sh
- Update tsconfig rootDir: src → plugin
- Update README.md and README.zh.md with new layout
- Bump version to 0.2.0
- All tests pass
This commit is contained in:
zhi
2026-03-10 14:44:40 +00:00
parent 00ffef0d8e
commit a0e926594f
30 changed files with 260 additions and 81 deletions

0
plugin/commands/.gitkeep Normal file
View File

View File

@@ -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 }
};

View File

@@ -0,0 +1,16 @@
export interface MemoryPort {
store(input: { text: string; scope: string; metadata?: Record<string, string> }): Promise<unknown>;
recall(input: { query: string; scope: string; limit?: number }): Promise<unknown>;
}
export class ScopeMemory {
constructor(private readonly memory: MemoryPort) {}
async put(scopeId: string, text: string, metadata?: Record<string, string>): Promise<unknown> {
return this.memory.store({ text, scope: scopeId, metadata });
}
async search(scopeId: string, query: string, limit = 5): Promise<unknown> {
return this.memory.recall({ query, scope: scopeId, limit });
}
}

View File

@@ -0,0 +1,10 @@
export interface AuditLogEntry {
id: string;
ts: string;
actorId: string;
action: string;
target?: string;
status: 'ok' | 'error';
message?: string;
meta?: Record<string, unknown>;
}

View File

@@ -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<string, unknown>
) {
super(message);
this.name = 'YonexusError';
}
}

View File

@@ -0,0 +1,93 @@
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<string, string>;
}
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_organization"
| "create_department"
| "create_team"
| "register_agent"
| "assign_identity"
| "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;
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;
}

View File

@@ -0,0 +1,40 @@
import { YonexusError } from '../models/errors';
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_organization" && orgAdmin) ||
(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 YonexusError('PERMISSION_DENIED', `permission_denied: ${action}`, {
action,
actorId: actor.agentId,
scope
});
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,156 @@
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<StoreState>(filePath, DEFAULT_STATE);
}
save(): void {
writeJsonFile(this.filePath, this.state);
}
snapshot(): StoreState {
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();
return org;
}
addDepartment(dept: Department): Department {
this.state.departments.push(dept);
this.save();
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();
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);
}
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;
}
listIdentities(): Identity[] {
return this.state.identities;
}
findSupervisor(agentId: string): Supervisor | undefined {
return this.state.supervisors.find((s) => s.agentId === agentId);
}
}

18
plugin/core/utils/fs.ts Normal file
View File

@@ -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<T>(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");
}

10
plugin/core/utils/id.ts Normal file
View File

@@ -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"}`;
}

245
plugin/core/yonexus.ts Normal file
View File

@@ -0,0 +1,245 @@
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,
DocsScope,
DocsTopic,
Identity,
QueryInput,
Scope,
StoreState,
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 { ResourceLayout } from "../tools/resources";
import { makeId } from "./utils/id";
export interface YonexusOptions {
dataFile?: string;
schema?: YonexusSchema;
registrars?: string[];
openclawDir?: string;
}
export class Yonexus {
private readonly schema: YonexusSchema;
private readonly registrars: Set<string>;
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<AuditLogEntry, "id" | "ts">): void {
this.audit.append({
id: makeId("audit", `${entry.actorId}-${entry.action}-${Date.now()}`),
ts: new Date().toISOString(),
...entry
});
}
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" });
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);
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;
}
registerAgent(actor: Actor, agentId: string, name: string, roles: Agent["roles"] = ["agent"]) {
if (this.registrars.size > 0 && !this.registrars.has(actor.agentId)) {
throw new YonexusError("REGISTRAR_DENIED", `registrar_denied: ${actor.agentId}`);
}
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 });
}
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<string, string>): Identity {
authorize("assign_identity", actor, { deptId, teamId }, this.store);
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
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<string, string> = {};
for (const [field, value] of Object.entries(meta)) {
if (!this.schema[field]) continue;
validatedMeta[field] = String(value);
}
const result = this.store.addIdentity({
id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`),
agentId,
deptId,
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;
}
setSupervisor(actor: Actor, agentId: string, supervisorId: string, deptId?: string) {
authorize("set_supervisor", actor, { deptId }, this.store);
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 YonexusError("NOT_FOUND", `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);
}
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);
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();
}
}

0
plugin/hooks/.gitkeep Normal file
View File

45
plugin/index.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Yonexus Plugin Entry
*
* This file is the wiring layer: it initializes and re-exports the core
* Yonexus class along with models, tools, and memory adapters.
* No business logic lives here.
*/
// ── Core ────────────────────────────────────────────────────────────────
export { Yonexus, type YonexusOptions } from "./core/yonexus";
// ── Models ──────────────────────────────────────────────────────────────
export { YonexusError } from "./core/models/errors";
export type { ErrorCode } from "./core/models/errors";
export type { AuditLogEntry } from "./core/models/audit";
export type {
Action,
Actor,
Agent,
Department,
DocsScope,
DocsTopic,
Identity,
Organization,
QueryFilter,
QueryInput,
QueryOptions,
Role,
SchemaField,
Scope,
StoreState,
Supervisor,
Team,
YonexusSchema,
} from "./core/models/types";
// ── Tools ───────────────────────────────────────────────────────────────
export { queryIdentities } from "./tools/query";
export { ResourceLayout } from "./tools/resources";
// ── Memory ──────────────────────────────────────────────────────────────
export { ScopeMemory, type MemoryPort } from "./core/memory/scopeMemory";
// ── Default export ──────────────────────────────────────────────────────
export { Yonexus as default } from "./core/yonexus";

75
plugin/tools/query.ts Normal file
View File

@@ -0,0 +1,75 @@
import { YonexusError } from '../core/models/errors';
import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../core/models/types";
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;
const regexCache = new Map<string, RegExp>();
const containsCache = new Map<string, string>();
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);
}
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(normalizeNeedle(filter.value));
case "regex": {
const re = getRegex(filter.value);
return re.test(raw);
}
default:
return false;
}
}
function normalizeOptions(options?: QueryOptions): Required<QueryOptions> {
const limit = Math.min(Math.max(1, options?.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
const offset = Math.max(0, options?.offset ?? 0);
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)) {
throw new YonexusError('FIELD_NOT_QUERYABLE', `field_not_queryable: ${filter.field}`, {
field: filter.field
});
}
}
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);
}

111
plugin/tools/resources.ts Normal file
View File

@@ -0,0 +1,111 @@
import fs from 'node:fs';
import path from 'node:path';
import { YonexusError } from '../core/models/errors';
import type { DocsScope, DocsTopic } from '../core/models/types';
import { slug } from '../core/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');
}
}