Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
ffc227f
Learn skills from fixed errors, consolidate as proposals, link memory…
helpcodeai Oct 19, 2026
3ea7ab7
Address review findings in memory, folders, skills and MCP (#1157)
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
c07c030
Merge branch 'helpcodeai/knowledge-folders' into helpcodeai/brain-lea…
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
4c7f6dd
Merge branch 'helpcodeai/knowledge-folders' into helpcodeai/brain-lea…
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
28946c6
Merge branch 'helpcodeai/knowledge-folders' into helpcodeai/brain-lea…
helpcodeai Oct 19, 2026
ab053b2
Merge commit '5d8b1d42f71512db27a45acc2e50ade72153343b' into helpcode…
helpcodeai Oct 19, 2026
8ca17f7
Merge commit '6dd860b53d487b65cef16ff7b15147fdc3575f52' into helpcode…
helpcodeai Oct 19, 2026
72d95a4
Merge commit '7f58be58e70ae9614856997216a2597bde23e391' into helpcode…
helpcodeai Oct 19, 2026
a5e17fd
Merge commit 'a5c76c4710004d3fe57b5934deccc52c9bd245eb' 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
20 changes: 16 additions & 4 deletions docs/knowledge-graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,22 @@ whole MCP server.
- **Auto-apply (optional).** Turn on *Auto-apply high-confidence skills*
(Settings → Features) and generated skills at/above **0.90** confidence are
applied automatically; lower-confidence ones still wait for review.
- **Consolidate with AI.** The *Consolidate with AI* button merges a scope's
**active** skills into the fewest non-redundant ones (same connector/server
target selector as Generate). Pending and dismissed skills are left untouched,
and a bad/empty model reply never wipes live skills.
- **Consolidate with AI.** The *Consolidate* button proposes merges of a
scope's **active** skills (same connector/server target selector as
Generate). Each merged skill is filed as a *Suggested* proposal that lists
the skills it replaces; applying it deactivates those (they stay, with their
history, and can be activated again). Nothing live changes until you apply a
proposal. Skills are merged only within one folder, and skills with a
procedure are left alone. A bad or empty model reply changes nothing.
- **Learn from fixed errors.** *Learn from fixed errors* (and every skill
generation) looks at the last 30 days of calls by users who contribute to
skill learning. When a call failed and the same person's next call of the
same tool, within 10 minutes, worked after an argument changed format (for
example a date sent as `99.99.9999` and then as `9999-99-99`), and that
happened at least twice, it proposes a skill on the tool's connector. Only
argument names and value formats are used, never the values; errors quoted
in the proposal are masked. No AI model is needed. Proposals you dismissed
are not proposed again.

### Who contributes to skill learning

Expand Down
14 changes: 12 additions & 2 deletions docs/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,20 @@ unchanged.

| Tool | Offered from | What it does |
|---|---|---|
| `memory_search(query?, scope?, limit?)` | Read | Full-text search over the company memory and, from Personal up, the user's own items. An empty query returns the most recent items. |
| `memory_save(content, kind?, scope?, tags?)` | Personal | Saves a fact, preference or note. `scope: "organization"` is offered from Contribute. |
| `memory_search(query?, scope?, limit?, about?, folder?)` | Read | Full-text search over the company memory and, from Personal up, the user's own items. An empty query returns the most recent items. |
| `memory_save(content, kind?, scope?, tags?, about?, folder?)` | Personal | Saves a fact, preference or note. `scope: "organization"` is offered from Contribute. |
| `memory_forget(id)` | Personal | Deletes one of the user's own personal items. Company memory is changed in the dashboard only. |

### Memory and the knowledge graph

An item can say what it is about, as the [knowledge graph](knowledge-graph.md)
names it (`about: "customer"`, normalised like graph entities, so "Customers"
becomes `customer`). `memory_save` and `memory_search` accept `about`, and the
Memory page has an *About* field. When an AI client asks `kg_how_to_obtain`
about an entity, the answer also lists the facts about it that the user may
read (their memory access and folders apply), marked as information written by
people.

## Access per user

Each user has one of these levels in a workspace (Settings → Users → *Memory*,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Whether a call's caller contributed to skill learning when it was made.
-- A constant default on a new column is a catalog-only change in Postgres:
-- no rewrite of tool_invocations. Existing calls are not learnable.
ALTER TABLE "tool_invocations" ADD COLUMN "learnable" BOOLEAN NOT NULL DEFAULT false;

-- Consolidation proposals record the applied skills they replace.
ALTER TABLE "kg_skill_suggestions" ADD COLUMN "replaces" JSONB NOT NULL DEFAULT '[]';

-- Memory items can be about a knowledge-graph entity.
ALTER TABLE "memory_items" ADD COLUMN "about" TEXT;
CREATE INDEX "memory_items_organization_id_about_idx" ON "memory_items"("organization_id", "about");
19 changes: 16 additions & 3 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,11 @@ model ToolInvocation {
// a minute is counted here instead of stored again (see AuditService).
repeatCount Int @default(1) @map("repeat_count")

// Whether the caller contributed to skill learning when the call was made
// (stamped at capture time, so changing a member's setting later never
// pulls old calls in). Only learnable calls feed skill proposals.
learnable Boolean @default(false)

createdAt DateTime @default(now()) @map("created_at")

@@index([toolId, createdAt])
Expand Down Expand Up @@ -1385,10 +1390,14 @@ model KgSkillSuggestion {
confidence Float @default(0)
evidenceCount Int @default(0) @map("evidence_count")
status String @default("pending") // pending | applied | dismissed
// Where the skill came from: manual (UI/API) | llm (generated or
// consolidated) | finder (skills_save_to_workspace). Generation only
// replaces its own pending `llm` rows.
// Where the skill came from: manual (UI/API) | llm (generated from
// intents) | consolidation (merged proposal) | errors (learned from calls
// that failed and were fixed) | finder (skills_save_to_workspace). Each
// generator only replaces its own pending rows.
source String @default("manual")
// For a consolidation proposal: the applied skills it replaces; applying
// it dismisses them.
replaces Json @default("[]")
// Optional long procedure (markdown). Never pasted into the instructions:
// served on demand by skill_get.
body String? @db.Text
Expand Down Expand Up @@ -1483,6 +1492,9 @@ model MemoryItem {
status String @default("active") // active | pending | rejected
// { via: "mcp" | "ui", mcpServerId?, client? }
source Json @default("{}")
// Knowledge-graph entity the item is about (e.g. "customer"), normalised
// like graph entities; kg_how_to_obtain offers matching facts.
about String?
// Optional folder (organization items only; personal memory has none).
folderId String? @map("folder_id")
folder KnowledgeFolder? @relation(fields: [folderId], references: [id], onDelete: NoAction)
Expand All @@ -1496,6 +1508,7 @@ model MemoryItem {

@@index([organizationId, scope, status])
@@index([organizationId, ownerUserId])
@@index([organizationId, about])
@@index([searchVector], type: Gin)
@@map("memory_items")
}
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/audit/audit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export class AuditService implements OnModuleDestroy {
usedProxy?: boolean;
/** Natural-language user intent that led to the call (opt-in capture). */
intent?: string;
/** The caller contributed to skill learning when the call was made. */
learnable?: boolean;
input: Record<string, unknown>;
output?: Record<string, unknown>;
status: 'SUCCESS' | 'ERROR' | 'TIMEOUT';
Expand Down Expand Up @@ -128,6 +130,7 @@ export class AuditService implements OnModuleDestroy {
connectorId: data.connectorId,
usedProxy: data.usedProxy ?? false,
intent: data.intent,
learnable: data.learnable ?? false,
input: input as any,
output: output as any,
status: data.status as InvocationStatus,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,21 +93,21 @@ export class KnowledgeFoldersController {
@Roles('ADMIN', 'EDITOR')
@ApiOperation({ summary: 'Create a folder' })
async create(@Req() req: any, @Body() body: CreateFolderDto) {
return this.folders.create(req.user.organizationId, body);
return this.folders.create(req.user.organizationId, body, req.user.sub);
}

@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');
return this.folders.update(req.user.organizationId, id, body, req.user.role === 'ADMIN', req.user.sub);
}

@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);
return this.folders.remove(req.user.organizationId, id, req.user.sub);
}

@Put(':id/roles')
Expand All @@ -131,6 +131,7 @@ export class KnowledgeFoldersController {
body.ids,
body.folderId ?? null,
req.user.role === 'ADMIN',
req.user.sub,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,19 @@ describe('KnowledgeFoldersService never widens access by accident', () => {
await expect(svc.remove(ORG, 'a')).rejects.toThrow(/limited to different roles/);
});
});

describe('KnowledgeFoldersService: only folders the caller can see', () => {
it('refuses an editor acting on a folder hidden from them, and caps the count', async () => {
const { svc } = make({ roles: [] });
// 'big' and 'sales' are limited to roles this editor lacks.
await expect(svc.update(ORG, 'sales', { name: 'X' }, false, 'u1')).rejects.toBeInstanceOf(NotFoundException);
await expect(svc.remove(ORG, 'sales', 'u1')).rejects.toBeInstanceOf(NotFoundException);
await expect(svc.create(ORG, { name: 'Sub', parentId: 'quotes' }, 'u1')).rejects.toBeInstanceOf(NotFoundException);
await expect(svc.moveItems(ORG, 'skill', ['s1'], 'quotes', false, 'u1')).rejects.toBeInstanceOf(NotFoundException);
// A visible folder works.
await expect(svc.update(ORG, 'hr', { description: 'People' }, false, 'u1')).resolves.toBeDefined();

const many = Array.from({ length: 500 }, (_, i) => ({ id: `f${i}`, parentId: null, name: `F${i}`, description: null, roleIds: [] }));
await expect(make({ rows: many }).svc.create(ORG, { name: 'One more' })).rejects.toThrow(/at most 500/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { EditionService } from '../ee/licensing/edition.service';

/** Deepest folder level (1 = top level). */
export const FOLDER_MAX_DEPTH = 4;
/** Most folders one workspace may have (the tree is read on every MCP request). */
export const FOLDER_MAX_COUNT = 500;
const FOLDER_NAME_MAX = 80;
const FOLDER_DESCRIPTION_MAX = 500;
/** Separator used to show and to resolve folder paths ("Sales / Quotes"). */
Expand Down Expand Up @@ -257,10 +259,15 @@ export class KnowledgeFoldersService {
async create(
organizationId: string,
input: { name: string; parentId?: string | null; description?: string | null },
actorUserId?: string,
) {
const name = cleanName(input.name);
const folders = await this.tree(organizationId);
if (folders.length >= FOLDER_MAX_COUNT) {
throw new BadRequestException(`A workspace can have at most ${FOLDER_MAX_COUNT} folders.`);
}
const parent = input.parentId ? this.mustFind(folders, input.parentId) : null;
await this.assertVisible(organizationId, actorUserId, [parent?.id ?? null]);
if ((parent?.depth ?? 0) + 1 > FOLDER_MAX_DEPTH) {
throw new BadRequestException(`Folders go at most ${FOLDER_MAX_DEPTH} levels deep.`);
}
Expand All @@ -282,9 +289,14 @@ export class KnowledgeFoldersService {
id: string,
patch: { name?: string; description?: string | null; parentId?: string | null },
actorIsAdmin = false,
actorUserId?: string,
) {
const folders = await this.tree(organizationId);
const folder = this.mustFind(folders, id);
await this.assertVisible(organizationId, actorUserId, [
folder.id,
patch.parentId !== undefined ? patch.parentId || null : null,
]);
const data: Record<string, unknown> = {};
let parentId = folder.parentId;
if (patch.parentId !== undefined) {
Expand Down Expand Up @@ -320,9 +332,10 @@ export class KnowledgeFoldersService {
* level); nothing else is deleted. A subfolder whose name is taken there is
* renamed with a suffix.
*/
async remove(organizationId: string, id: string) {
async remove(organizationId: string, id: string, actorUserId?: string) {
const folders = await this.tree(organizationId);
const folder = this.mustFind(folders, id);
await this.assertVisible(organizationId, actorUserId, [folder.id]);
const parentId = folder.parentId;
const limit = folder.roleIds;
// A limited folder must not hand its contents to a wider audience.
Expand Down Expand Up @@ -416,18 +429,22 @@ export class KnowledgeFoldersService {
ids: string[],
folderId: string | null,
actorIsAdmin = false,
actorUserId?: string,
): Promise<{ moved: number }> {
const folders = await this.tree(organizationId);
if (folderId) this.mustFind(folders, folderId);
const where = { organizationId, id: { in: [...new Set(ids)].slice(0, 500) } };
if (!actorIsAdmin) {
const current =
kind === 'skill'
? await this.prisma.kgSkillSuggestion.findMany({ where, select: { folderId: true } })
: await this.prisma.memoryItem.findMany({ where, select: { folderId: true } });
if (current.some((r) => widensAccess(folders, r.folderId ?? null, folderId))) {
throw new ForbiddenException(WIDEN_MESSAGE);
}
const current =
kind === 'skill'
? await this.prisma.kgSkillSuggestion.findMany({ where, select: { folderId: true } })
: await this.prisma.memoryItem.findMany({ where, select: { folderId: true } });
// Source and target must be folders the caller can see.
await this.assertVisible(organizationId, actorUserId, [
folderId,
...current.map((r) => r.folderId ?? null),
]);
if (!actorIsAdmin && current.some((r) => widensAccess(folders, r.folderId ?? null, folderId))) {
throw new ForbiddenException(WIDEN_MESSAGE);
}
const res =
kind === 'skill'
Expand All @@ -446,6 +463,20 @@ export class KnowledgeFoldersService {
return f;
}

/**
* The caller may only act on folders they can see; a hidden one answers
* like a missing one. `null` (the top level) is always visible. Skipped
* for internal calls without an actor.
*/
private async assertVisible(organizationId: string, actorUserId: string | undefined, ids: Array<string | null>) {
if (!actorUserId) return;
const wanted = ids.filter((id): id is string => !!id);
if (!wanted.length) return;
const access = await this.accessFor(organizationId, actorUserId);
if (access.all) return;
if (wanted.some((id) => !access.ids.has(id))) throw new NotFoundException('Folder not found.');
}

private mustFind(folders: FolderNode[], id: string): FolderNode {
const f = folders.find((x) => x.id === id);
if (!f) throw new NotFoundException('Folder not found.');
Expand Down
97 changes: 97 additions & 0 deletions packages/backend/src/knowledge-graph/error-fix.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { diffArgs, findFixPatterns, shapeOf } from './error-fix';
import { errorFixSkill } from './kg-skill.service';

const t0 = new Date('2026-10-19T10:00:00Z').getTime();
const at = (min: number) => new Date(t0 + min * 60_000);
const call = (userId: string | null, status: string, input: unknown, min: number, toolId = 'tool-1', error: string | null = null) => ({
toolId,
userId,
status,
input,
error,
createdAt: at(min),
});

describe('shapeOf', () => {
it('keeps the format and drops the value', () => {
expect(shapeOf('12.10.2026')).toBe('"99.99.9999"');
expect(shapeOf('2026-10-12')).toBe('"9999-99-99"');
expect(shapeOf('Müller')).toBe('"aaaaaa"');
expect(shapeOf('x'.repeat(40))).toBe('text(40 chars)');
expect(shapeOf(42)).toBe('integer');
expect(shapeOf(undefined)).toBe('missing');
expect(shapeOf([1, 2])).toBe('list(integer)');
});
});

describe('diffArgs', () => {
it('reports arguments whose format changed, never values, and ignores _ params', () => {
expect(
diffArgs({ date: '12.10.2026', customer: 'Anna', _intent: 'x' }, { date: '2026-10-12', customer: 'Bert' }),
).toEqual([{ key: 'date', before: '"99.99.9999"', after: '"9999-99-99"' }]);
expect(diffArgs({ q: 'a' }, { q: 'a', limit: 10 })).toEqual([{ key: 'limit', before: 'missing', after: 'integer' }]);
});
});

describe('findFixPatterns', () => {
const fixed = (user: string, start: number) => [
call(user, 'ERROR', { date: '12.10.2026' }, start, 'tool-1', 'Invalid date 12.10.2026'),
call(user, 'SUCCESS', { date: '2026-10-12' }, start + 2),
];

it('finds a failure fixed by a format change, counted per occurrence and person', () => {
const rows = [...fixed('u1', 0), ...fixed('u1', 30), ...fixed('u2', 5)].sort(
(a, b) => a.userId!.localeCompare(b.userId!) || a.createdAt.getTime() - b.createdAt.getTime(),
);
const [p] = findFixPatterns(rows);
expect(p).toEqual(
expect.objectContaining({
toolId: 'tool-1',
occurrences: 3,
users: 2,
changes: [{ key: 'date', before: '"99.99.9999"', after: '"9999-99-99"' }],
}),
);
});

it('ignores single occurrences, slow fixes, other people, retries and value-only fixes', () => {
expect(findFixPatterns(fixed('u1', 0))).toEqual([]);
const slow = [call('u1', 'ERROR', { date: '12.10.2026' }, 0), call('u1', 'SUCCESS', { date: '2026-10-12' }, 30)];
expect(findFixPatterns([...slow, ...slow.map((r) => ({ ...r, createdAt: new Date(r.createdAt.getTime() + 3_600_000) }))])).toEqual([]);
const retry = [call('u1', 'ERROR', { id: 'A1' }, 0), call('u1', 'SUCCESS', { id: 'A1' }, 1)];
expect(findFixPatterns([...retry, ...retry])).toEqual([]);
const valueOnly = [call('u1', 'ERROR', { id: 'A1' }, 0), call('u1', 'SUCCESS', { id: 'B2' }, 1)];
expect(findFixPatterns([...valueOnly, ...valueOnly])).toEqual([]);
const anonymous = [call(null, 'ERROR', { date: '12.10.2026' }, 0), call(null, 'SUCCESS', { date: '2026-10-12' }, 1)];
expect(findFixPatterns([...anonymous, ...anonymous])).toEqual([]);
});
});

describe('errorFixSkill', () => {
const pattern = (error: string | null) => ({
toolId: 't',
signature: 's',
changes: [{ key: 'date', before: '"99.99.9999"', after: '"9999-99-99"' }],
occurrences: 3,
users: 2,
error,
});

it('writes the rule with names and formats, and keeps only an error code', () => {
const skill = errorFixSkill('erp_create_order', pattern('VIES rejected: INVALID_INPUT for Hans Müller GmbH 12.10.2026'));
expect(skill.title).toBe('erp_create_order: `date` as "9999-99-99"');
expect(skill.instruction).toContain('Send `date` as "9999-99-99"');
expect(skill.instruction).toContain('failed (INVALID_INPUT)');
expect(skill.instruction).not.toMatch(/Hans|Müller|12\.10/);
expect(skill.instruction).toContain('3 times, 2 people');
});

it('keeps an HTTP status, or nothing', () => {
expect(errorFixSkill('t', pattern('Request failed with status 422: customer anna@example.com')).instruction).toContain(
'failed (HTTP 422)',
);
const none = errorFixSkill('t', pattern('Customer "Hans Müller" not found')).instruction;
expect(none).toContain('Calls with `date` as "99.99.9999" failed and worked');
expect(none).not.toContain('Hans');
});
});
Loading
Loading