fraim-hub 2.0.243 → 2.0.245
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/dist/src/ai-hub/catalog.js +38 -8
- package/dist/src/ai-hub/custom-employees.js +99 -33
- package/dist/src/ai-hub/server.js +4 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +232 -77
- package/package.json +2 -2
- package/public/ai-hub/review.css +139 -0
- package/public/ai-hub/script.js +567 -71
- package/public/ai-hub/styles.css +18 -0
|
@@ -22,14 +22,27 @@ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
|
22
22
|
// - <project>/fraim/ai-employee/jobs/<category>/ - synced baseline
|
|
23
23
|
// - <project>/fraim/personalized-employee/jobs/<category>/ - taught/customized override
|
|
24
24
|
// Only the personalized-employee layer is "personalized" (issue #566).
|
|
25
|
+
// Issue #1002: the machine-level layers are added here so a capability authored
|
|
26
|
+
// at the manager level is DISCOVERABLE, not merely resolvable by exact name.
|
|
27
|
+
// Order matters and mirrors LocalRegistryResolver.resolveFile precedence
|
|
28
|
+
// inverted, because later entries win on a {categoryId, jobId} collision:
|
|
29
|
+
// synced baseline < company cache < manager synced cache < manager local < project
|
|
30
|
+
// A job that resolves from the manager level must therefore also DISPLAY as the
|
|
31
|
+
// manager's, otherwise the rail would show a baseline job the runtime never runs.
|
|
25
32
|
const EMPLOYEE_JOB_LAYERS = [
|
|
26
33
|
{ segments: ['ai-employee', 'jobs'], personalized: false },
|
|
27
|
-
{ segments: ['
|
|
34
|
+
{ base: 'user', segments: ['org', 'jobs'], personalized: true, scope: 'org' },
|
|
35
|
+
{ base: 'user', segments: ['manager', 'jobs'], personalized: true, scope: 'manager' },
|
|
36
|
+
{ base: 'user', segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'manager' },
|
|
37
|
+
{ segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'project' },
|
|
28
38
|
];
|
|
29
39
|
// Manager templates use the matching layer model.
|
|
30
40
|
const MANAGER_JOB_LAYERS = [
|
|
31
41
|
{ segments: ['ai-manager', 'jobs'], personalized: false },
|
|
32
|
-
{ segments: ['
|
|
42
|
+
{ base: 'user', segments: ['org', 'manager-jobs'], personalized: true, scope: 'org' },
|
|
43
|
+
{ base: 'user', segments: ['manager', 'manager-jobs'], personalized: true, scope: 'manager' },
|
|
44
|
+
{ base: 'user', segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'manager' },
|
|
45
|
+
{ segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'project' },
|
|
33
46
|
];
|
|
34
47
|
const REGISTRY_EMPLOYEE_JOB_LAYERS = [
|
|
35
48
|
{ base: 'project', segments: ['registry', 'jobs', 'ai-employee'], personalized: false },
|
|
@@ -93,7 +106,18 @@ function readMarkdownFileNames(dirPath) {
|
|
|
93
106
|
.map((entry) => entry.name)
|
|
94
107
|
.sort((a, b) => a.localeCompare(b));
|
|
95
108
|
}
|
|
96
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Issue #1002: a stub that lives under the machine-level FRAIM home is displayed
|
|
111
|
+
* as `~/.fraim/...` rather than as a long `../../..` walk out of the project,
|
|
112
|
+
* which is what path.relative would otherwise produce for a user-level layer.
|
|
113
|
+
*/
|
|
114
|
+
function stubDisplayPath(filePath, projectPath, userLevel) {
|
|
115
|
+
if (!userLevel)
|
|
116
|
+
return toPosix(path_1.default.relative(projectPath, filePath));
|
|
117
|
+
const rel = toPosix(path_1.default.relative((0, project_fraim_paths_1.getUserFraimDirPath)(), filePath));
|
|
118
|
+
return (0, project_fraim_paths_1.getUserFraimDisplayPath)(rel);
|
|
119
|
+
}
|
|
120
|
+
function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized, scope, userLevel) {
|
|
97
121
|
const content = fs_1.default.readFileSync(filePath, 'utf8');
|
|
98
122
|
const fileName = path_1.default.basename(filePath, '.md');
|
|
99
123
|
const frontmatter = readJobFrontmatter(filePath);
|
|
@@ -107,11 +131,12 @@ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personal
|
|
|
107
131
|
categoryLabel,
|
|
108
132
|
intent,
|
|
109
133
|
outcome,
|
|
110
|
-
stubPath:
|
|
134
|
+
stubPath: stubDisplayPath(filePath, projectPath, userLevel),
|
|
111
135
|
personalized: !!personalized,
|
|
136
|
+
...(scope ? { scope } : {}),
|
|
112
137
|
};
|
|
113
138
|
}
|
|
114
|
-
function parseManagerStub(filePath, groupId, groupLabel, projectPath) {
|
|
139
|
+
function parseManagerStub(filePath, groupId, groupLabel, projectPath, scope, userLevel) {
|
|
115
140
|
const content = fs_1.default.readFileSync(filePath, 'utf8');
|
|
116
141
|
const fileName = path_1.default.basename(filePath, '.md');
|
|
117
142
|
const frontmatter = readJobFrontmatter(filePath);
|
|
@@ -123,7 +148,8 @@ function parseManagerStub(filePath, groupId, groupLabel, projectPath) {
|
|
|
123
148
|
groupId,
|
|
124
149
|
groupLabel,
|
|
125
150
|
intent,
|
|
126
|
-
stubPath:
|
|
151
|
+
stubPath: stubDisplayPath(filePath, projectPath, userLevel),
|
|
152
|
+
...(scope ? { scope } : {}),
|
|
127
153
|
};
|
|
128
154
|
}
|
|
129
155
|
function summarizeProject(projectPath) {
|
|
@@ -163,6 +189,8 @@ function summarizeProject(projectPath) {
|
|
|
163
189
|
function resolveLayerRoot(projectPath, layer) {
|
|
164
190
|
if (layer.base === 'project')
|
|
165
191
|
return path_1.default.join(projectPath, ...layer.segments);
|
|
192
|
+
if (layer.base === 'user')
|
|
193
|
+
return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), ...layer.segments);
|
|
166
194
|
return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
|
|
167
195
|
}
|
|
168
196
|
function discoverLayers(projectPath, layers) {
|
|
@@ -175,6 +203,8 @@ function discoverLayers(projectPath, layers) {
|
|
|
175
203
|
categoryId: categoryName,
|
|
176
204
|
categoryDir: path_1.default.join(layerRoot, categoryName),
|
|
177
205
|
personalized: layer.personalized,
|
|
206
|
+
scope: layer.scope,
|
|
207
|
+
userLevel: layer.base === 'user',
|
|
178
208
|
});
|
|
179
209
|
}
|
|
180
210
|
}
|
|
@@ -193,7 +223,7 @@ function discoverEmployeeJobs(projectPath, options = {}) {
|
|
|
193
223
|
const categoryLabel = humanizeName(layer.categoryId);
|
|
194
224
|
for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
|
|
195
225
|
const filePath = path_1.default.join(layer.categoryDir, fileName);
|
|
196
|
-
const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized);
|
|
226
|
+
const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized, layer.scope, layer.userLevel);
|
|
197
227
|
// Later layers override earlier layers on {category, jobId} collision —
|
|
198
228
|
// personalized-employee wins over the synced ai-employee baseline. The
|
|
199
229
|
// winning job carries its own layer's `personalized` flag (issue #566).
|
|
@@ -219,7 +249,7 @@ function discoverManagerTemplates(projectPath, options = {}) {
|
|
|
219
249
|
const groupLabel = humanizeName(layer.categoryId);
|
|
220
250
|
for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
|
|
221
251
|
const filePath = path_1.default.join(layer.categoryDir, fileName);
|
|
222
|
-
const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath);
|
|
252
|
+
const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath, layer.scope, layer.userLevel);
|
|
223
253
|
templatesByKey.set(`${template.groupId}::${template.id}`, template);
|
|
224
254
|
}
|
|
225
255
|
}
|
|
@@ -17,63 +17,129 @@ exports.buildCustomEmployeePersona = buildCustomEmployeePersona;
|
|
|
17
17
|
// evaluatePersonaAccess) must never call resolveEmployee.
|
|
18
18
|
const fs_1 = __importDefault(require("fs"));
|
|
19
19
|
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
20
21
|
const persona_hiring_1 = require("../config/persona-hiring");
|
|
21
22
|
const EMPLOYEES_DIR_REL = path_1.default.join('fraim', 'personalized-employee', 'employees');
|
|
22
23
|
function employeesDir(projectDir) {
|
|
23
24
|
return path_1.default.join(projectDir, EMPLOYEES_DIR_REL);
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return slug;
|
|
35
|
-
}
|
|
36
|
-
function filePath(projectDir, key) {
|
|
37
|
-
const slug = safeSlug(key);
|
|
38
|
-
if (!slug)
|
|
39
|
-
return null;
|
|
40
|
-
return path_1.default.join(employeesDir(projectDir), `${slug}.json`);
|
|
26
|
+
// Issue #1002: the manager level is the default home for a custom employee, so
|
|
27
|
+
// an employee the manager created is available in every project on the machine.
|
|
28
|
+
// Two roots, matching capability precedence: the writable home wins over the
|
|
29
|
+
// synced cache, so a just-created employee appears before any publish or sync.
|
|
30
|
+
function managerEmployeesDirs() {
|
|
31
|
+
return {
|
|
32
|
+
local: path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', 'employees'),
|
|
33
|
+
cache: path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'manager', 'employees'),
|
|
34
|
+
};
|
|
41
35
|
}
|
|
42
|
-
function
|
|
43
|
-
const dir = employeesDir(projectDir);
|
|
36
|
+
function readEmployeesFromDir(dir, scope) {
|
|
44
37
|
if (!fs_1.default.existsSync(dir))
|
|
45
38
|
return [];
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
let names;
|
|
40
|
+
try {
|
|
41
|
+
names = fs_1.default.readdirSync(dir);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const file of names) {
|
|
48
48
|
if (!file.endsWith('.json'))
|
|
49
49
|
continue;
|
|
50
50
|
try {
|
|
51
|
-
const
|
|
52
|
-
const parsed = JSON.parse(raw);
|
|
51
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8'));
|
|
53
52
|
if (parsed && typeof parsed.key === 'string' && parsed.key.startsWith('custom:')) {
|
|
54
|
-
|
|
53
|
+
// The record's own scope is authoritative when present; otherwise the
|
|
54
|
+
// directory it was found in names its level. A record written before this
|
|
55
|
+
// change carries scope 'project' and is found in the project dir, so both
|
|
56
|
+
// agree and nothing is reinterpreted.
|
|
57
|
+
out.push({ ...parsed, scope: parsed.scope ?? scope });
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
catch {
|
|
58
61
|
// silently skip malformed files
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
|
-
return
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function slugFor(key) {
|
|
67
|
+
// key is 'custom:<slug>' — file is named '<slug>.json'
|
|
68
|
+
return key.replace(/^custom:/, '');
|
|
62
69
|
}
|
|
70
|
+
function safeSlug(key) {
|
|
71
|
+
// Reject keys whose slug component contains path separators or traversal sequences.
|
|
72
|
+
const slug = slugFor(key);
|
|
73
|
+
if (!slug || /[/\\]|\.\./.test(slug))
|
|
74
|
+
return null;
|
|
75
|
+
return slug;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read every custom employee visible from this project, across levels.
|
|
79
|
+
*
|
|
80
|
+
* Issue #1002: precedence matches capability resolution. The project copy wins
|
|
81
|
+
* on a key collision, then the manager's writable home, then the synced manager
|
|
82
|
+
* cache. Reading the manager level here is what makes the manager-level default
|
|
83
|
+
* usable: without it an employee created at the manager level would exist on
|
|
84
|
+
* disk and appear in no roster.
|
|
85
|
+
*/
|
|
86
|
+
function readCustomEmployees(projectDir) {
|
|
87
|
+
const dirs = managerEmployeesDirs();
|
|
88
|
+
const byKey = new Map();
|
|
89
|
+
// Lowest precedence first, so a later write overwrites on key collision.
|
|
90
|
+
for (const emp of readEmployeesFromDir(dirs.cache, 'manager'))
|
|
91
|
+
byKey.set(emp.key, emp);
|
|
92
|
+
for (const emp of readEmployeesFromDir(dirs.local, 'manager'))
|
|
93
|
+
byKey.set(emp.key, emp);
|
|
94
|
+
for (const emp of readEmployeesFromDir(employeesDir(projectDir), 'project'))
|
|
95
|
+
byKey.set(emp.key, emp);
|
|
96
|
+
return [...byKey.values()];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Write a custom employee at its declared level.
|
|
100
|
+
*
|
|
101
|
+
* Issue #1002 R1: the manager level is the default, so a record with no scope,
|
|
102
|
+
* or scope 'manager', is written to the manager's writable home and is available
|
|
103
|
+
* in every project. Only scope 'project' writes into the repository.
|
|
104
|
+
*/
|
|
63
105
|
function writeCustomEmployee(projectDir, employee) {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
106
|
+
const scope = employee.scope ?? 'manager';
|
|
107
|
+
const record = { ...employee, scope };
|
|
108
|
+
const slug = safeSlug(record.key);
|
|
109
|
+
if (!slug)
|
|
110
|
+
throw new Error(`Invalid employee key: ${record.key}`);
|
|
111
|
+
const dir = scope === 'project' ? employeesDir(projectDir) : managerEmployeesDirs().local;
|
|
68
112
|
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
69
|
-
fs_1.default.writeFileSync(
|
|
113
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, `${slug}.json`), JSON.stringify(record, null, 2), 'utf8');
|
|
70
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Delete a custom employee wherever it lives. Issue #1002: a manager-level
|
|
117
|
+
* employee is not in the project directory, so deleting only there would report
|
|
118
|
+
* success while leaving the record live in every other project.
|
|
119
|
+
*/
|
|
71
120
|
function deleteCustomEmployee(projectDir, key) {
|
|
72
|
-
const
|
|
73
|
-
if (!
|
|
121
|
+
const slug = safeSlug(key);
|
|
122
|
+
if (!slug)
|
|
74
123
|
return false;
|
|
75
|
-
|
|
76
|
-
|
|
124
|
+
const dirs = managerEmployeesDirs();
|
|
125
|
+
const candidates = [
|
|
126
|
+
path_1.default.join(employeesDir(projectDir), `${slug}.json`),
|
|
127
|
+
path_1.default.join(dirs.local, `${slug}.json`),
|
|
128
|
+
path_1.default.join(dirs.cache, `${slug}.json`),
|
|
129
|
+
];
|
|
130
|
+
let removed = false;
|
|
131
|
+
for (const fp of candidates) {
|
|
132
|
+
if (!fs_1.default.existsSync(fp))
|
|
133
|
+
continue;
|
|
134
|
+
try {
|
|
135
|
+
fs_1.default.unlinkSync(fp);
|
|
136
|
+
removed = true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// keep going, and report whether anything was removed
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return removed;
|
|
77
143
|
}
|
|
78
144
|
function slugifyDisplayName(displayName, existingKeys = []) {
|
|
79
145
|
const base = displayName
|
|
@@ -3188,8 +3188,12 @@ class AiHubServer {
|
|
|
3188
3188
|
computeBrain(projectPath, jobCount, userEmail) {
|
|
3189
3189
|
const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, userEmail || '');
|
|
3190
3190
|
if (!userEmail) {
|
|
3191
|
+
// Issue #1002 R11: reverseMentoring is individual-keyed like manager and
|
|
3192
|
+
// project, so it is zeroed on the same condition. Missing it would leak a
|
|
3193
|
+
// count for a user whose identity was never resolved.
|
|
3191
3194
|
learnings.manager = 0;
|
|
3192
3195
|
learnings.project = 0;
|
|
3196
|
+
learnings.reverseMentoring = 0;
|
|
3193
3197
|
}
|
|
3194
3198
|
return {
|
|
3195
3199
|
learnings,
|