Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a27664c
Add per-member skill learning control and fix skill provenance (#1145)
helpcodeai Oct 19, 2026
332638b
Serve workspace skills on demand with procedures and versions (#1148)
helpcodeai Oct 19, 2026
9465111
Add workspace memory for AI clients (Enterprise) (#1151)
helpcodeai Oct 19, 2026
198d430
Add folders for skills and company memory, with role limits (#1153)
helpcodeai Oct 19, 2026
2e20bba
Tolerate an incomplete folder list response and cover /memory on phon…
helpcodeai Oct 19, 2026
031bfd6
Pin every server that receives applied skills today to inline (#1148)
helpcodeai Oct 19, 2026
e029c60
Merge branch 'helpcodeai/skills-v2' into helpcodeai/workspace-memory
helpcodeai Oct 19, 2026
4e3a455
Merge branch 'helpcodeai/workspace-memory' into helpcodeai/knowledge-…
helpcodeai Oct 19, 2026
33c56bb
Never cascade or null a folder reference: deletes cannot widen access…
helpcodeai Oct 19, 2026
b2ad139
Merge remote-tracking branch 'origin/main' into helpcodeai/anythingmc…
helpcodeai Oct 19, 2026
35b5b55
Order the learning migration after main's 20261019120000 (#1145)
helpcodeai Oct 19, 2026
5311f42
Merge branch 'helpcodeai/anythingmcp-skills-memory' into helpcodeai/s…
helpcodeai Oct 19, 2026
0500752
Merge branch 'helpcodeai/skills-v2' into helpcodeai/workspace-memory
helpcodeai Oct 19, 2026
395865b
Merge branch 'helpcodeai/workspace-memory' into helpcodeai/knowledge-…
helpcodeai Oct 19, 2026
da24d63
Merge remote-tracking branch 'origin/main' into helpcodeai/anythingmc…
helpcodeai Oct 19, 2026
c1d48fd
Merge branch 'helpcodeai/anythingmcp-skills-memory' into helpcodeai/s…
helpcodeai Oct 19, 2026
c82791b
Merge branch 'helpcodeai/skills-v2' into helpcodeai/workspace-memory
helpcodeai Oct 19, 2026
40a80dc
Merge branch 'helpcodeai/workspace-memory' into helpcodeai/knowledge-…
helpcodeai Oct 19, 2026
1ae8cbf
Merge commit '5d8b1d42f71512db27a45acc2e50ade72153343b' into helpcode…
helpcodeai Oct 19, 2026
e4f85aa
Merge commit '6dd860b53d487b65cef16ff7b15147fdc3575f52' into helpcode…
helpcodeai Oct 19, 2026
59a642a
Merge commit '7f58be58e70ae9614856997216a2597bde23e391' into helpcode…
helpcodeai Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/knowledge-graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,15 @@ The shared `/mcp` endpoint keeps its fixed toolset: its workspace guide
(`anythingmcp_get_workspace_guide`) returns the skills as text, procedures
included.

**Folders.** Skills can be filed in folders (AI Skills page, left column),
the same tree the company memory uses; see [Folders](memory.md#folders). With
folders, the index groups skills by folder, and when it does not fit it lists
the top-level folders with their description and number of skills;
`skills_list(folder: "Sales / Quotes")` opens one. A folder limited to roles
hides its skills from everyone without one of those roles, on every path
(instructions, `skills_list`, `skill_get`, prompts, `kg_how_to_obtain`, the
dashboard).

**Versions and usage.** Editing a skill's title, *when to use*, instruction or
procedure keeps the previous content as a version (see *History* on the skill)
and increments its version number. Each `skill_get` or prompt use is counted, so
Expand Down
28 changes: 28 additions & 0 deletions docs/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,34 @@ Either way, each item records who saved it and from which client, and admins
and editors can edit or delete company memory on the Memory page. They can
also add to it directly.

## Folders

Company memory and skills share one folder tree per workspace, so a topic
such as "Sales / Quotes" holds both the procedures (skills) and the facts
(memory). Folders are available in every edition.

- Admins and editors create, rename, describe, move and delete folders from
the left column of the Memory page (Company memory tab) or the AI Skills
page. Folders go up to 4 levels deep. Deleting a folder moves its subfolders
and items to its parent; nothing is deleted.
- Personal memory has no folders; use tags.
- `memory_search(folder: "Sales")` searches a folder and its subfolders.
`memory_save` can file a company item in an existing folder. AI clients
never create folders. Results show each item's folder.

### Limiting folders to roles (Enterprise)

An admin can limit a folder to one or more custom roles (Settings → Roles,
including roles synced from the identity provider): edit the folder → *Visible
to*. The limit applies to the folder and everything below it, for skills and
memory alike, on every path: instructions, `skills_list` / `skill_get`,
prompts, `memory_search`, `kg_how_to_obtain` and the dashboard. Workspace
admins see every folder. For everyone else a limited folder does not exist.

Setting limits needs an Enterprise licence. Limits already set keep applying
without it, so access never widens on its own. Every change is logged as
`KNOWLEDGE_FOLDER_ACCESS_CHANGED`.

## Privacy and safety

- Content that looks like a password, token or key is refused.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-- One folder tree per workspace, shared by skills and the company memory.
-- `role_ids` limits a folder and its subtree to members holding one of those
-- custom roles; empty = the whole workspace. Nothing is moved into folders.
CREATE TABLE "knowledge_folders" (
"id" TEXT NOT NULL,
"organization_id" TEXT NOT NULL,
"parent_id" TEXT,
"name" TEXT NOT NULL,
"description" TEXT,
"role_ids" JSONB NOT NULL DEFAULT '[]',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "knowledge_folders_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "knowledge_folders_organization_id_idx" ON "knowledge_folders"("organization_id");
CREATE INDEX "knowledge_folders_parent_id_idx" ON "knowledge_folders"("parent_id");

ALTER TABLE "knowledge_folders" ADD CONSTRAINT "knowledge_folders_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Folder deletion is done by the application (children and items move to the
-- parent first). NO ACTION everywhere: a child or item added meanwhile makes
-- the delete fail instead of being cascaded away or set to the top level
-- (which could widen who sees it). Deleting a workspace still works: every
-- row cascades from the organization in the same statement.
ALTER TABLE "knowledge_folders" ADD CONSTRAINT "knowledge_folders_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "knowledge_folders"("id") ON DELETE NO ACTION ON UPDATE CASCADE;

ALTER TABLE "kg_skill_suggestions" ADD COLUMN "folder_id" TEXT;
ALTER TABLE "kg_skill_suggestions" ADD CONSTRAINT "kg_skill_suggestions_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "knowledge_folders"("id") ON DELETE NO ACTION ON UPDATE CASCADE;

ALTER TABLE "memory_items" ADD COLUMN "folder_id" TEXT;
ALTER TABLE "memory_items" ADD CONSTRAINT "memory_items_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "knowledge_folders"("id") ON DELETE NO ACTION ON UPDATE CASCADE;
36 changes: 36 additions & 0 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ model Organization {
kgConnectorState KgConnectorState[]
kgValueSeen KgValueSeen[]
memoryItems MemoryItem[]
knowledgeFolders KnowledgeFolder[]
kgSkills KgSkillSuggestion[]
securityEvents SecurityEvent[]
productEvents ProductEvent[]
Expand Down Expand Up @@ -1397,6 +1398,9 @@ model KgSkillSuggestion {
useCount Int @default(0) @map("use_count")
lastUsedAt DateTime? @map("last_used_at")
versions KgSkillVersion[]
// Optional folder in the workspace's knowledge tree (shared with memory).
folderId String? @map("folder_id")
folder KnowledgeFolder? @relation(fields: [folderId], references: [id], onDelete: NoAction)

createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
Expand Down Expand Up @@ -1479,6 +1483,9 @@ model MemoryItem {
status String @default("active") // active | pending | rejected
// { via: "mcp" | "ui", mcpServerId?, client? }
source Json @default("{}")
// Optional folder (organization items only; personal memory has none).
folderId String? @map("folder_id")
folder KnowledgeFolder? @relation(fields: [folderId], references: [id], onDelete: NoAction)
createdByUserId String? @map("created_by_user_id")
approvedByUserId String? @map("approved_by_user_id")
approvedAt DateTime? @map("approved_at")
Expand All @@ -1492,3 +1499,32 @@ model MemoryItem {
@@index([searchVector], type: Gin)
@@map("memory_items")
}

// A folder of the workspace's knowledge tree, shared by skills and the
// company memory (e.g. "Sales / Quotes" holds quote procedures and facts).
// `roleIds` limits it, and everything below it, to members holding one of
// those custom roles (Enterprise); empty means the whole workspace.
model KnowledgeFolder {
id String @id @default(cuid())
organizationId String @map("organization_id")
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parentId String? @map("parent_id")
// NO ACTION, not cascade/set-null: a folder is only removed by the service,
// after moving its contents; a racing child or item makes the delete fail
// instead of silently reaching a wider audience. Workspace deletion still
// removes everything (all rows cascade from the organization).
parent KnowledgeFolder? @relation("KnowledgeFolderTree", fields: [parentId], references: [id], onDelete: NoAction)
children KnowledgeFolder[] @relation("KnowledgeFolderTree")
name String
description String? @db.Text
roleIds Json @default("[]") @map("role_ids")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

skills KgSkillSuggestion[]
memoryItems MemoryItem[]

@@index([organizationId])
@@index([parentId])
@@map("knowledge_folders")
}
2 changes: 2 additions & 0 deletions packages/backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module, MiddlewareConsumer, NestModule, Logger, ClassSerializerInterceptor } from '@nestjs/common';

Check warning on line 1 in packages/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Backend (lint, typecheck, test, build)

'ClassSerializerInterceptor' is defined but never used. Allowed unused vars must match /^_/u
import { ConfigModule, ConfigService } from '@nestjs/config';
import { join } from 'path';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
Expand All @@ -19,6 +19,7 @@
import { RolesModule } from './roles/roles.module';
import { KgModule } from './knowledge-graph/kg.module';
import { MemoryModule } from './memory/memory.module';
import { KnowledgeFoldersModule } from './knowledge-folders/knowledge-folders.module';
import { McpServersModule } from './mcp-servers/mcp-servers.module';
import { LicenseModule } from './license/license.module';
import { PrismaModule } from './common/prisma.module';
Expand Down Expand Up @@ -176,6 +177,7 @@
RolesModule,
KgModule,
MemoryModule,
KnowledgeFoldersModule,
McpServersModule,
LicenseModule,
LicensingModule,
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/audit/security-event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export const SecurityEvents = {
MEMORY_SETTINGS_CHANGED: 'MEMORY_SETTINGS_CHANGED',
/** An admin erased a member's personal memory (without reading it). */
MEMORY_ERASED: 'MEMORY_ERASED',
/** The roles a knowledge folder (and its subtree) is limited to changed. */
KNOWLEDGE_FOLDER_ACCESS_CHANGED: 'KNOWLEDGE_FOLDER_ACCESS_CHANGED',

// ── Consent plane: which AI client was authorized, by whom ──────────────
CONSENT_GRANTED: 'CONSENT_GRANTED',
Expand Down
49 changes: 31 additions & 18 deletions packages/backend/src/ee/licensing/edition.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ export const TRANSITION_DAYS = 60;
const PAID_PLANS = new Set(['starter', 'team', 'business', 'enterprise']);
/** Paid plans that include single sign-on, SCIM and role sync. */
const BUSINESS_PLANS = new Set(['business', 'enterprise']);
/** Plans that include workspace memory. Not unlocked by the trial or the transition. */
const MEMORY_PLANS = new Set(['enterprise']);
/** How long a workspace's memory entitlement is cached (it is read on every MCP request). */
const MEMORY_CACHE_MS = 60_000;
/**
* Plans that include the Enterprise workspace features (workspace memory,
* folder restrictions by role). Not unlocked by the trial or the transition.
*/
const ENTERPRISE_PLANS = new Set(['enterprise']);
/** How long a workspace's Enterprise entitlement is cached (read on every MCP request). */
const ENTERPRISE_CACHE_MS = 60_000;
const TRIAL_KEY = 'business_trial_ends_at';
const TRANSITION_KEY = 'business_transition_until';
const DAY_MS = 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -181,34 +184,44 @@ export class EditionService implements OnModuleInit {
};
}

private readonly memoryCache = new Map<string, { value: boolean; at: number }>();
private readonly enterpriseCache = new Map<string, { value: boolean; at: number }>();

/**
* Whether a workspace may use workspace memory: an active, unexpired licence
* with an Enterprise plan. On Cloud that is the workspace's own licence; on
* a self-hosted instance the workspace's or else the instance's key. The
* trial and the transition period do not include it.
* Whether a workspace has the Enterprise workspace features: an active,
* unexpired licence with an Enterprise plan. On Cloud that is the
* workspace's own licence; on a self-hosted instance the workspace's or
* else the instance's key. The trial and the transition period do not
* include them.
*/
async hasMemory(organizationId: string, now = new Date()): Promise<boolean> {
async hasEnterprise(organizationId: string, now = new Date()): Promise<boolean> {
if (!organizationId) return false;
const hit = this.memoryCache.get(organizationId);
if (hit && now.getTime() - hit.at < MEMORY_CACHE_MS) return hit.value;
const hit = this.enterpriseCache.get(organizationId);
if (hit && now.getTime() - hit.at < ENTERPRISE_CACHE_MS) return hit.value;
const license = await this.licenses.getCurrentLicense(organizationId);
const value = this.isPaid(license, now) && MEMORY_PLANS.has(license!.plan);
this.memoryCache.set(organizationId, { value, at: now.getTime() });
const value = this.isPaid(license, now) && ENTERPRISE_PLANS.has(license!.plan);
this.enterpriseCache.set(organizationId, { value, at: now.getTime() });
return value;
}

/** Throws the 403 the frontend recognises when memory is not licensed. */
async assertMemory(organizationId: string): Promise<void> {
if (await this.hasMemory(organizationId)) return;
/** Throws the 403 the frontend recognises when the feature is not licensed. */
async assertEnterprise(organizationId: string, feature: string): Promise<void> {
if (await this.hasEnterprise(organizationId)) return;
throw new ForbiddenException({
statusCode: 403,
code: EDITION_REQUIRED,
message: 'Workspace memory is available with AnythingMCP Enterprise.',
message: `${feature} is available with AnythingMCP Enterprise.`,
});
}

/** Workspace memory (Enterprise). */
hasMemory(organizationId: string, now = new Date()): Promise<boolean> {
return this.hasEnterprise(organizationId, now);
}

assertMemory(organizationId: string): Promise<void> {
return this.assertEnterprise(organizationId, 'Workspace memory');
}

async hasBusiness(): Promise<boolean> {
if (this.deployment.isCloud()) return true;
return (await this.getState()).business;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiOperation, ApiProperty, ApiPropertyOptional, ApiTags } from '@nestjs/swagger';
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { Roles, RolesGuard } from '../auth/roles.guard';
import { KnowledgeFoldersService } from './knowledge-folders.service';

class CreateFolderDto {
@ApiProperty({ description: 'Folder name (no "/").' })
@IsString()
@MaxLength(80)
name: string;

@ApiPropertyOptional({ description: 'Parent folder id; omit for a top-level folder.' })
@IsOptional()
@IsString()
parentId?: string | null;

@ApiPropertyOptional({ description: 'What belongs here; shown to AI clients when skills are listed by folder.' })
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
}

class UpdateFolderDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(80)
name?: string;

@ApiPropertyOptional({ description: 'New parent id; null moves the folder to the top level.' })
@IsOptional()
@IsString()
parentId?: string | null;

@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
}

class FolderRolesDto {
@ApiProperty({ type: [String], description: 'Custom role ids; empty = the whole workspace.' })
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
roleIds: string[];
}

class MoveItemsDto {
@ApiProperty({ enum: ['skill', 'memory'] })
@IsIn(['skill', 'memory'])
kind: 'skill' | 'memory';

@ApiProperty({ type: [String] })
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(500)
@IsString({ each: true })
ids: string[];

@ApiPropertyOptional({ description: 'Target folder id; null = top level.' })
@IsOptional()
@IsString()
folderId?: string | null;
}

@ApiTags('Knowledge folders')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Controller('api/knowledge-folders')
export class KnowledgeFoldersController {
constructor(private readonly folders: KnowledgeFoldersService) {}

@Get()
@ApiOperation({ summary: 'The folders I may see, with skill and company-memory counts' })
async list(@Req() req: any) {
return this.folders.listForCaller(req.user.organizationId, req.user.sub);
}

@Post()
@Roles('ADMIN', 'EDITOR')
@ApiOperation({ summary: 'Create a folder' })
async create(@Req() req: any, @Body() body: CreateFolderDto) {
return this.folders.create(req.user.organizationId, body);
}

@Patch(':id')
@Roles('ADMIN', 'EDITOR')
@ApiOperation({ summary: 'Rename, describe or move a folder' })
async update(@Req() req: any, @Param('id') id: string, @Body() body: UpdateFolderDto) {
return this.folders.update(req.user.organizationId, id, body, req.user.role === 'ADMIN');
}

@Delete(':id')
@Roles('ADMIN', 'EDITOR')
@ApiOperation({ summary: 'Delete a folder; its subfolders and items move to its parent' })
async remove(@Req() req: any, @Param('id') id: string) {
return this.folders.remove(req.user.organizationId, id);
}

@Put(':id/roles')
@Roles('ADMIN')
@ApiOperation({ summary: 'Limit a folder and its subtree to roles (ADMIN, Enterprise)' })
async setRoles(@Req() req: any, @Param('id') id: string, @Body() body: FolderRolesDto) {
return this.folders.setRoles(req.user.organizationId, id, body.roleIds, {
actorUserId: req.user.sub,
ip: req.ip,
userAgent: req.headers?.['user-agent'],
});
}

@Post('move')
@Roles('ADMIN', 'EDITOR')
@ApiOperation({ summary: 'Move skills or company memory items into a folder' })
async move(@Req() req: any, @Body() body: MoveItemsDto) {
return this.folders.moveItems(
req.user.organizationId,
body.kind,
body.ids,
body.folderId ?? null,
req.user.role === 'ADMIN',
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Global, Module } from '@nestjs/common';
import { KnowledgeFoldersController } from './knowledge-folders.controller';
import { KnowledgeFoldersService } from './knowledge-folders.service';

/**
* The workspace's knowledge tree (folders shared by skills and the company
* memory). Global: skills, memory and the MCP endpoint scope by it.
*/
@Global()
@Module({
controllers: [KnowledgeFoldersController],
providers: [KnowledgeFoldersService],
exports: [KnowledgeFoldersService],
})
export class KnowledgeFoldersModule {}
Loading
Loading