@capekai/core 1.0.11 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capekai/core",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Bun-native composable agent runtime and framework for Capek.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,6 +42,7 @@ export { executeSchedulerTool } from '../scheduler/scheduler-tool';
42
42
  export { executeSessionSearchTool } from '../session-search/session-search-tool';
43
43
  export { executeSkillManageTool, buildSkillManageToolDescription } from '../skills/skill-manage-tool';
44
44
  export { executeMemoryTool } from '../memory/memory-tool';
45
+ export { withKnowledgeMutationLock } from '../runtime/knowledge-mutation-lock';
45
46
  export {
46
47
  addEntry,
47
48
  entriesToContent,
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { mkdir, readFile, writeFile } from 'fs/promises';
3
3
  import { join } from 'path';
4
+ import { withKnowledgeMutationLock } from '../runtime/knowledge-mutation-lock';
4
5
 
5
6
  const USER_FILE = 'USER.md';
6
7
  const MEMORY_FILE = 'MEMORY.md';
@@ -72,7 +73,11 @@ function fullResult(existing: string, target: MemoryTarget, entries: string[], r
72
73
  };
73
74
  }
74
75
 
75
- export async function addEntry(basePath: string, target: MemoryTarget, content: string): Promise<MemoryActionResult> {
76
+ export function addEntry(basePath: string, target: MemoryTarget, content: string): Promise<MemoryActionResult> {
77
+ return withKnowledgeMutationLock(basePath, () => addEntryUnlocked(basePath, target, content));
78
+ }
79
+
80
+ async function addEntryUnlocked(basePath: string, target: MemoryTarget, content: string): Promise<MemoryActionResult> {
76
81
  const trimmed = content.trim();
77
82
  if (!trimmed) return { success: false, error: 'Content cannot be empty.' };
78
83
  const entry = `- ${trimmed}`;
@@ -115,7 +120,11 @@ function findOne(content: string, entries: string[], target: MemoryTarget, oldTe
115
120
  return matches[0];
116
121
  }
117
122
 
118
- export async function replaceEntry(basePath: string, target: MemoryTarget, oldText: string, content: string): Promise<MemoryActionResult> {
123
+ export function replaceEntry(basePath: string, target: MemoryTarget, oldText: string, content: string): Promise<MemoryActionResult> {
124
+ return withKnowledgeMutationLock(basePath, () => replaceEntryUnlocked(basePath, target, oldText, content));
125
+ }
126
+
127
+ async function replaceEntryUnlocked(basePath: string, target: MemoryTarget, oldText: string, content: string): Promise<MemoryActionResult> {
119
128
  const trimmed = content.trim();
120
129
  if (!trimmed) return { success: false, error: 'New content cannot be empty.' };
121
130
  const loaded = await readExisting(basePath, target);
@@ -128,7 +137,11 @@ export async function replaceEntry(basePath: string, target: MemoryTarget, oldTe
128
137
  return { success: true, result: { target, action: 'replace', path: fileName(target), usage: { chars: next.length, limit: charLimit(target) }, entry: trimmed } };
129
138
  }
130
139
 
131
- export async function removeEntry(basePath: string, target: MemoryTarget, oldText: string): Promise<MemoryActionResult> {
140
+ export function removeEntry(basePath: string, target: MemoryTarget, oldText: string): Promise<MemoryActionResult> {
141
+ return withKnowledgeMutationLock(basePath, () => removeEntryUnlocked(basePath, target, oldText));
142
+ }
143
+
144
+ async function removeEntryUnlocked(basePath: string, target: MemoryTarget, oldText: string): Promise<MemoryActionResult> {
132
145
  const loaded = await readExisting(basePath, target);
133
146
  if ('success' in loaded) return loaded;
134
147
  const match = findOne(loaded.content, loaded.entries, target, oldText);
@@ -0,0 +1,45 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { realpathSync } from 'node:fs';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+
5
+ const tails = new Map<string, Promise<void>>();
6
+ const active = new AsyncLocalStorage<{ held: boolean }>();
7
+
8
+ function directoryKey(directory: string): string {
9
+ if (!directory.trim() || directory.includes('\0')) throw new Error('Invalid knowledge directory');
10
+ let current = resolve(directory);
11
+ const missing: string[] = [];
12
+ for (;;) {
13
+ try {
14
+ return join(realpathSync(current), ...missing.reverse());
15
+ } catch (error: unknown) {
16
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
17
+ const parent = dirname(current);
18
+ if (parent === current) throw error;
19
+ missing.push(basename(current));
20
+ current = parent;
21
+ }
22
+ }
23
+ }
24
+
25
+ /** Serialize managed read-modify-write operations by canonical directory.
26
+ * Process-local only: external writers must be handled by host revision policy.
27
+ * Do not call managed memory/skill mutators inside this callback; they acquire
28
+ * the same primitive themselves. Permission waits belong outside the lock. */
29
+ export async function withKnowledgeMutationLock<T>(directory: string, callback: () => Promise<T>): Promise<T> {
30
+ if (active.getStore()?.held) throw new Error('Nested knowledge mutation locks are not supported');
31
+ const key = directoryKey(directory);
32
+ const previous = tails.get(key) ?? Promise.resolve();
33
+ let release!: () => void;
34
+ const tail = new Promise<void>(resolve => { release = resolve; });
35
+ tails.set(key, tail);
36
+ await previous;
37
+ const state = { held: true };
38
+ try {
39
+ return await active.run(state, callback);
40
+ } finally {
41
+ state.held = false;
42
+ release();
43
+ if (tails.get(key) === tail) tails.delete(key);
44
+ }
45
+ }
@@ -4,6 +4,7 @@ import { join } from 'path';
4
4
  import type { PermissionAsk } from '@capekai/tool';
5
5
  import { PermissionRiskLevel } from '@capekai/tool';
6
6
  import { scanSkillsFromDir } from './registry';
7
+ import { withKnowledgeMutationLock } from '../runtime/knowledge-mutation-lock';
7
8
 
8
9
  type SkillManageAction = 'list' | 'create' | 'update' | 'patch' | 'delete';
9
10
  export interface SkillManageResult {
@@ -137,49 +138,51 @@ export async function executeSkillManageTool(input: Record<string, unknown>, roo
137
138
  if (!approved) return { success: false, error: 'USER_REJECTION' };
138
139
  }
139
140
 
140
- if (action === 'create') {
141
- if (!description) return { success: false, error: 'description is required for create action.' };
142
- if (!content) return { success: false, error: 'content is required for create action.' };
143
- const path = skillPath(root, safeName);
144
- if (existsSync(path)) return { success: false, error: `Skill "${safeName}" already exists. Use update or patch instead.` };
145
- await mkdir(skillDir(root, safeName), { recursive: true });
146
- await writeFile(path, frontmatter(safeName, description) + '\n' + content + '\n', 'utf-8');
147
- return { success: true, title: `Skill created: ${safeName}`, action, name: safeName, description, path: `${safeName}/SKILL.md`, summary: 'Created workspace skill.' };
148
- }
141
+ return withKnowledgeMutationLock(root, async (): Promise<SkillManageResult> => {
142
+ if (action === 'create') {
143
+ if (!description) return { success: false, error: 'description is required for create action.' };
144
+ if (!content) return { success: false, error: 'content is required for create action.' };
145
+ const path = skillPath(root, safeName);
146
+ if (existsSync(path)) return { success: false, error: `Skill "${safeName}" already exists. Use update or patch instead.` };
147
+ await mkdir(skillDir(root, safeName), { recursive: true });
148
+ await writeFile(path, frontmatter(safeName, description) + '\n' + content + '\n', 'utf-8');
149
+ return { success: true, title: `Skill created: ${safeName}`, action, name: safeName, description, path: `${safeName}/SKILL.md`, summary: 'Created workspace skill.' };
150
+ }
149
151
 
150
- const resolved = await resolveSkillFolder(rawName, root);
151
- if (!resolved) {
152
- const names = await availableNames(root);
153
- return { success: false, error: `Skill "${rawName}" does not exist.${names.length ? ` Available skills: ${names.join(', ')}` : action === 'delete' ? '' : ' No skills exist yet. Use create first.'}` };
154
- }
155
- const relativePath = `${resolved.folderName}/SKILL.md`;
156
- if (action === 'delete') {
157
- await rm(skillDir(root, resolved.folderName), { recursive: true, force: true });
158
- return { success: true, title: `Skill deleted: ${resolved.folderName}`, action, name: resolved.folderName, path: relativePath, summary: 'Removed workspace skill directory.' };
159
- }
160
- let existing: string;
161
- try { existing = await readFile(resolved.skillMdPath, 'utf-8'); } catch { return { success: false, error: 'Failed to read existing skill file.' }; }
162
-
163
- if (action === 'update') {
164
- if (!content) return { success: false, error: 'content is required for update action.' };
165
- const existingDescription = existing.match(/^description:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '') ?? '';
166
- const existingName = existing.match(/^name:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '').trim() ?? resolved.folderName;
167
- const effectiveDescription = description ?? existingDescription;
168
- await writeFile(resolved.skillMdPath, frontmatter(existingName, effectiveDescription) + '\n' + content + '\n', 'utf-8');
169
- return { success: true, title: `Skill updated: ${existingName}`, action, name: existingName, description: effectiveDescription, path: relativePath, summary: 'Replaced skill body.' };
170
- }
152
+ const resolved = await resolveSkillFolder(rawName, root);
153
+ if (!resolved) {
154
+ const names = await availableNames(root);
155
+ return { success: false, error: `Skill "${rawName}" does not exist.${names.length ? ` Available skills: ${names.join(', ')}` : action === 'delete' ? '' : ' No skills exist yet. Use create first.'}` };
156
+ }
157
+ const relativePath = `${resolved.folderName}/SKILL.md`;
158
+ if (action === 'delete') {
159
+ await rm(skillDir(root, resolved.folderName), { recursive: true, force: true });
160
+ return { success: true, title: `Skill deleted: ${resolved.folderName}`, action, name: resolved.folderName, path: relativePath, summary: 'Removed workspace skill directory.' };
161
+ }
162
+ let existing: string;
163
+ try { existing = await readFile(resolved.skillMdPath, 'utf-8'); } catch { return { success: false, error: 'Failed to read existing skill file.' }; }
164
+
165
+ if (action === 'update') {
166
+ if (!content) return { success: false, error: 'content is required for update action.' };
167
+ const existingDescription = existing.match(/^description:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '') ?? '';
168
+ const existingName = existing.match(/^name:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '').trim() ?? resolved.folderName;
169
+ const effectiveDescription = description ?? existingDescription;
170
+ await writeFile(resolved.skillMdPath, frontmatter(existingName, effectiveDescription) + '\n' + content + '\n', 'utf-8');
171
+ return { success: true, title: `Skill updated: ${existingName}`, action, name: existingName, description: effectiveDescription, path: relativePath, summary: 'Replaced skill body.' };
172
+ }
171
173
 
172
- if (!oldString) return { success: false, error: 'oldString is required for patch action.' };
173
- if (newString === undefined || newString === null) return { success: false, error: 'newString is required for patch action.' };
174
- const matches = existing.split(oldString).length - 1;
175
- if (matches === 0) return { success: false, error: 'oldString not found in skill file. Load the skill via the "skill" tool first to see the exact content, then copy the exact text to oldString.' };
176
- if (matches > 1) return { success: false, error: `oldString matched ${matches} locations. Provide a more specific oldString.` };
177
- let patched = existing.replace(oldString, newString);
178
- if (description) patched = patched.replace(/^(description:\s*).*$/m, `$1${description}`);
179
- await writeFile(resolved.skillMdPath, patched, 'utf-8');
180
- const resultName = patched.match(/^name:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '').trim() ?? resolved.folderName;
181
- const resultDescription = description ?? patched.match(/^description:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '');
182
- return { success: true, title: `Skill patched: ${resultName}`, action, name: resultName, description: resultDescription, path: relativePath, summary: 'Replaced one matching block.' };
174
+ if (!oldString) return { success: false, error: 'oldString is required for patch action.' };
175
+ if (newString === undefined || newString === null) return { success: false, error: 'newString is required for patch action.' };
176
+ const matches = existing.split(oldString).length - 1;
177
+ if (matches === 0) return { success: false, error: 'oldString not found in skill file. Load the skill via the "skill" tool first to see the exact content, then copy the exact text to oldString.' };
178
+ if (matches > 1) return { success: false, error: `oldString matched ${matches} locations. Provide a more specific oldString.` };
179
+ let patched = existing.replace(oldString, newString);
180
+ if (description) patched = patched.replace(/^(description:\s*).*$/m, `$1${description}`);
181
+ await writeFile(resolved.skillMdPath, patched, 'utf-8');
182
+ const resultName = patched.match(/^name:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '').trim() ?? resolved.folderName;
183
+ const resultDescription = description ?? patched.match(/^description:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '');
184
+ return { success: true, title: `Skill patched: ${resultName}`, action, name: resultName, description: resultDescription, path: relativePath, summary: 'Replaced one matching block.' };
185
+ });
183
186
  }
184
187
 
185
188
  export const SKILL_MANAGE_GUIDANCE = `You can create and update workspace skills using the skill_manage tool.