fraim 2.0.211 → 2.0.213
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/cli/commands/setup.js +3 -2
- package/dist/src/cli/utils/local-folder-sync.js +178 -0
- package/dist/src/cli/utils/manager-pack-sync.js +21 -1
- package/dist/src/cli/utils/manager-publish.js +13 -0
- package/dist/src/cli/utils/org-pack-sync.js +21 -1
- package/dist/src/cli/utils/org-publish.js +13 -0
- package/dist/src/cli/utils/user-config.js +37 -0
- package/dist/src/config/persona-capability-bundles.js +1 -1
- package/dist/src/core/ai-mentor.js +6 -10
- package/dist/src/core/fraim-config-schema.generated.js +19 -0
- package/dist/src/core/utils/include-resolver.js +15 -2
- package/dist/src/first-run/session-service.js +22 -18
- package/dist/src/local-mcp-server/learning-context-builder.js +146 -13
- package/dist/src/local-mcp-server/skill-include-dedup.js +46 -0
- package/dist/src/local-mcp-server/stdio-server.js +21 -1
- package/dist/src/middleware/auth.js +11 -0
- package/dist/src/middleware/telemetry.js +2 -1
- package/dist/src/services/mcp-service.js +18 -1
- package/package.json +1 -1
- package/public/first-run/script.js +5 -2
|
@@ -374,12 +374,13 @@ const runSetup = async (options) => {
|
|
|
374
374
|
}
|
|
375
375
|
}
|
|
376
376
|
else {
|
|
377
|
-
// Hub path: launch the Hub
|
|
377
|
+
// Hub path (#866 R1): launch the FRAIM Hub desktop app and let the user pick a
|
|
378
|
+
// project there. The desktop shell opens its own window, so there is no URL to print.
|
|
378
379
|
const { FirstRunSessionService } = await Promise.resolve().then(() => __importStar(require('../../first-run/session-service')));
|
|
379
380
|
const svc = new FirstRunSessionService({ key: 'setup-hub-launch', projectRoot: process.cwd() });
|
|
380
381
|
const result = await svc.openHub();
|
|
381
382
|
if (result.ok) {
|
|
382
|
-
console.log(chalk_1.default.green(
|
|
383
|
+
console.log(chalk_1.default.green('\n FRAIM Hub is opening in the desktop app.'));
|
|
383
384
|
console.log(chalk_1.default.gray(' Your AI employee will help you onboard your first project.'));
|
|
384
385
|
}
|
|
385
386
|
else {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Local-folder backend for per-layer learnings (issue #834).
|
|
4
|
+
*
|
|
5
|
+
* Supports OneDrive, SharePoint, Google Drive for Desktop, and any
|
|
6
|
+
* other sync client that maintains a locally-accessible folder. FRAIM
|
|
7
|
+
* reads from and writes to the declared local path; the OS sync client
|
|
8
|
+
* handles upload and download. No cloud API calls, no OAuth tokens.
|
|
9
|
+
*
|
|
10
|
+
* Layout expected under localPath:
|
|
11
|
+
* context/ — org_context.md / manager_context.md
|
|
12
|
+
* rules/ — org_rules.md / manager_rules.md
|
|
13
|
+
* learnings/ — synthesized learning files (allowlisted names only)
|
|
14
|
+
*
|
|
15
|
+
* Conflict resolution
|
|
16
|
+
* -------------------
|
|
17
|
+
* Sync clients write conflict copies when two machines write the same
|
|
18
|
+
* file before either syncs. FRAIM skips conflict copies on read (they
|
|
19
|
+
* never override the canonical file) and uses atomic temp-rename on
|
|
20
|
+
* write (minimises the window where the client picks up a partial file).
|
|
21
|
+
*
|
|
22
|
+
* Known conflict-copy patterns:
|
|
23
|
+
* OneDrive / SharePoint:
|
|
24
|
+
* "file (User Name's conflicted copy 2026-07-18).md"
|
|
25
|
+
* "file-UserName-PC.md" (some Windows clients)
|
|
26
|
+
* Google Drive for Desktop:
|
|
27
|
+
* "file (1).md"
|
|
28
|
+
* Dropbox:
|
|
29
|
+
* "file (Jane's conflicted copy 2026-07-18).md"
|
|
30
|
+
*/
|
|
31
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
32
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
33
|
+
};
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.CONFLICT_COPY_RE = exports.LOCAL_FOLDER_PACK_DIRS = void 0;
|
|
36
|
+
exports.isConflictCopy = isConflictCopy;
|
|
37
|
+
exports.countConflictCopies = countConflictCopies;
|
|
38
|
+
exports.collectLocalFolderFiles = collectLocalFolderFiles;
|
|
39
|
+
exports.writeLocalFolderFile = writeLocalFolderFile;
|
|
40
|
+
exports.localFolderVersion = localFolderVersion;
|
|
41
|
+
const fs_1 = __importDefault(require("fs"));
|
|
42
|
+
const path_1 = __importDefault(require("path"));
|
|
43
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
44
|
+
/** Pack subdirectories expected in every local-folder backend. */
|
|
45
|
+
exports.LOCAL_FOLDER_PACK_DIRS = ['context', 'rules', 'learnings'];
|
|
46
|
+
/**
|
|
47
|
+
* Matches the conflict-copy filename patterns produced by OneDrive,
|
|
48
|
+
* SharePoint, Google Drive for Desktop, and Dropbox.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately does NOT match ordinary parenthesised names like
|
|
51
|
+
* "sprint-retro (Q2 2026).md" — only the well-known suffix patterns.
|
|
52
|
+
*/
|
|
53
|
+
exports.CONFLICT_COPY_RE = /[\s-](\([^)]*'s conflicted copy \d{4}-\d{2}-\d{2}\)|\(\d+\)|[\w-]+-PC)\.md$/i;
|
|
54
|
+
/** True when the filename looks like a sync-client conflict copy. */
|
|
55
|
+
function isConflictCopy(fileName) {
|
|
56
|
+
return exports.CONFLICT_COPY_RE.test(fileName);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Count conflict-copy files directly under dirPath/learnings (non-recursive).
|
|
60
|
+
* Used by the Hub UI to surface an amber "N conflicts" indicator.
|
|
61
|
+
*/
|
|
62
|
+
function countConflictCopies(localPath) {
|
|
63
|
+
let count = 0;
|
|
64
|
+
for (const dirName of exports.LOCAL_FOLDER_PACK_DIRS) {
|
|
65
|
+
const dir = path_1.default.join(localPath, dirName);
|
|
66
|
+
if (!fs_1.default.existsSync(dir))
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
70
|
+
if (entry.isFile() && isConflictCopy(entry.name))
|
|
71
|
+
count++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch { /* non-fatal */ }
|
|
75
|
+
}
|
|
76
|
+
return count;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Collect pack files from the local folder, applying the same path
|
|
80
|
+
* allowlist as the git and cloud backends. Conflict copies are skipped.
|
|
81
|
+
*
|
|
82
|
+
* pathGuard is a function (relativePath) => boolean — callers pass
|
|
83
|
+
* isSafePackPath (org) or isManagerPackRelativePath (manager).
|
|
84
|
+
*/
|
|
85
|
+
function collectLocalFolderFiles(localPath, pathGuard) {
|
|
86
|
+
const files = [];
|
|
87
|
+
for (const dirName of exports.LOCAL_FOLDER_PACK_DIRS) {
|
|
88
|
+
const dir = path_1.default.join(localPath, dirName);
|
|
89
|
+
if (!fs_1.default.existsSync(dir))
|
|
90
|
+
continue;
|
|
91
|
+
try {
|
|
92
|
+
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
93
|
+
if (!entry.isFile())
|
|
94
|
+
continue;
|
|
95
|
+
if (isConflictCopy(entry.name))
|
|
96
|
+
continue;
|
|
97
|
+
const rel = `${dirName}/${entry.name}`;
|
|
98
|
+
if (!pathGuard(rel))
|
|
99
|
+
continue;
|
|
100
|
+
const content = fs_1.default.readFileSync(path_1.default.join(dir, entry.name), 'utf8');
|
|
101
|
+
files.push({ relativePath: rel, content });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch { /* non-fatal — missing subdirs are fine */ }
|
|
105
|
+
}
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Atomically write a file into the local folder.
|
|
110
|
+
*
|
|
111
|
+
* Writes to a hidden temp file first (.fraim-tmp-<uuid>.<ext>), then
|
|
112
|
+
* renames into place. This limits the window the OS sync client sees a
|
|
113
|
+
* partial file to the ~1ms of the rename syscall.
|
|
114
|
+
*
|
|
115
|
+
* Pre-write stale-read guard: if the target file exists and was modified
|
|
116
|
+
* more recently than lastSyncedAt, returns { skipped: true } so callers
|
|
117
|
+
* can re-sync before writing. Pass undefined to skip the guard.
|
|
118
|
+
*/
|
|
119
|
+
function writeLocalFolderFile(localPath, relativePath, content, lastSyncedAt) {
|
|
120
|
+
const dest = path_1.default.join(localPath, relativePath);
|
|
121
|
+
const dir = path_1.default.dirname(dest);
|
|
122
|
+
// Stale-read guard.
|
|
123
|
+
if (lastSyncedAt && fs_1.default.existsSync(dest)) {
|
|
124
|
+
const mtime = fs_1.default.statSync(dest).mtimeMs;
|
|
125
|
+
if (mtime > lastSyncedAt.getTime()) {
|
|
126
|
+
return {
|
|
127
|
+
written: false,
|
|
128
|
+
skipped: true,
|
|
129
|
+
reason: `Remote update detected (file mtime ${new Date(mtime).toISOString()} > lastSyncedAt ${lastSyncedAt.toISOString()}). Re-sync before writing.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
134
|
+
const ext = path_1.default.extname(relativePath);
|
|
135
|
+
const tmpName = `.fraim-tmp-${crypto_1.default.randomUUID()}${ext}`;
|
|
136
|
+
const tmp = path_1.default.join(dir, tmpName);
|
|
137
|
+
try {
|
|
138
|
+
fs_1.default.writeFileSync(tmp, content, 'utf8');
|
|
139
|
+
fs_1.default.renameSync(tmp, dest);
|
|
140
|
+
return { written: true, skipped: false };
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
try {
|
|
144
|
+
fs_1.default.rmSync(tmp, { force: true });
|
|
145
|
+
}
|
|
146
|
+
catch { /* ignore */ }
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A stable version string for the local-folder backend — a hex digest
|
|
152
|
+
* of the most recent mtime across all pack files. Changes whenever any
|
|
153
|
+
* file in the folder is written (by this machine or the sync client).
|
|
154
|
+
*/
|
|
155
|
+
function localFolderVersion(localPath) {
|
|
156
|
+
const mtimes = [];
|
|
157
|
+
for (const dirName of exports.LOCAL_FOLDER_PACK_DIRS) {
|
|
158
|
+
const dir = path_1.default.join(localPath, dirName);
|
|
159
|
+
if (!fs_1.default.existsSync(dir))
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
163
|
+
if (!entry.isFile() || isConflictCopy(entry.name))
|
|
164
|
+
continue;
|
|
165
|
+
try {
|
|
166
|
+
mtimes.push(fs_1.default.statSync(path_1.default.join(dir, entry.name)).mtimeMs);
|
|
167
|
+
}
|
|
168
|
+
catch { /* skip files we can't stat */ }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch { /* non-fatal */ }
|
|
172
|
+
}
|
|
173
|
+
if (mtimes.length === 0)
|
|
174
|
+
return '0';
|
|
175
|
+
const hash = crypto_1.default.createHash('sha1');
|
|
176
|
+
hash.update(mtimes.sort((a, b) => a - b).join(','));
|
|
177
|
+
return hash.digest('hex').slice(0, 12);
|
|
178
|
+
}
|
|
@@ -25,6 +25,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
25
25
|
const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
|
|
26
26
|
const manager_pack_1 = require("../../core/manager-pack");
|
|
27
27
|
const git_org_sync_1 = require("./git-org-sync");
|
|
28
|
+
const local_folder_sync_1 = require("./local-folder-sync");
|
|
28
29
|
const user_config_1 = require("./user-config");
|
|
29
30
|
exports.MANAGER_CACHE_DIRNAME = 'manager';
|
|
30
31
|
exports.MANAGER_SYNC_METADATA_FILE = '.manager-sync-metadata.json';
|
|
@@ -59,7 +60,9 @@ function decorateManagedManagerFile(content, backend) {
|
|
|
59
60
|
return normalized;
|
|
60
61
|
const writePath = backend === 'git'
|
|
61
62
|
? 'open a pull request against your manager storage repo'
|
|
62
|
-
:
|
|
63
|
+
: backend === 'local-folder'
|
|
64
|
+
? 'edit the file in your synced folder and let the sync client upload it'
|
|
65
|
+
: 'publish the change through your FRAIM manager storage flow';
|
|
63
66
|
const marker = [
|
|
64
67
|
exports.MANAGER_CACHE_MANAGED_HEADER,
|
|
65
68
|
'> [!IMPORTANT]',
|
|
@@ -151,7 +154,24 @@ async function syncManagerCache(options) {
|
|
|
151
154
|
if (!managerStorage)
|
|
152
155
|
return { status: 'disabled' };
|
|
153
156
|
}
|
|
157
|
+
// single-machine: no remote sync; content stays local. Return disabled so
|
|
158
|
+
// callers know there is nothing to refresh (issue #834).
|
|
159
|
+
if (managerStorage.backend === 'single-machine') {
|
|
160
|
+
return { status: 'disabled' };
|
|
161
|
+
}
|
|
154
162
|
try {
|
|
163
|
+
if (managerStorage.backend === 'local-folder') {
|
|
164
|
+
const localPath = managerStorage.localPath;
|
|
165
|
+
const files = (0, local_folder_sync_1.collectLocalFolderFiles)(localPath, manager_pack_1.isManagerPackRelativePath);
|
|
166
|
+
const metadata = {
|
|
167
|
+
version: (0, local_folder_sync_1.localFolderVersion)(localPath),
|
|
168
|
+
backend: 'local-folder',
|
|
169
|
+
source: localPath,
|
|
170
|
+
syncedAt: new Date().toISOString(),
|
|
171
|
+
};
|
|
172
|
+
materializeCache(files, metadata);
|
|
173
|
+
return { status: 'synced', metadata };
|
|
174
|
+
}
|
|
155
175
|
if (managerStorage.backend === 'git') {
|
|
156
176
|
const snapshot = (0, git_org_sync_1.fetchOrgRepoSnapshot)(managerStorage.gitUrl);
|
|
157
177
|
try {
|
|
@@ -16,6 +16,7 @@ const os_1 = __importDefault(require("os"));
|
|
|
16
16
|
const path_1 = __importDefault(require("path"));
|
|
17
17
|
const manager_pack_1 = require("../../core/manager-pack");
|
|
18
18
|
const user_config_1 = require("./user-config");
|
|
19
|
+
const local_folder_sync_1 = require("./local-folder-sync");
|
|
19
20
|
const ALLOWED_GIT_URL = /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/|[\w.-]+@[\w.-]+:)/;
|
|
20
21
|
function managerPackRelativePathFor(filePath) {
|
|
21
22
|
const relativePath = (0, manager_pack_1.managerPackRelativePathForFileName)(filePath);
|
|
@@ -42,6 +43,18 @@ async function publishManagerArtifacts(artifacts, opts) {
|
|
|
42
43
|
throw new Error(`Invalid manager-pack path '${artifact.relativePath}' (must be context/manager_context.md, rules/manager_rules.md, or learnings/<user>-*.md).`);
|
|
43
44
|
}
|
|
44
45
|
}
|
|
46
|
+
if (managerStorage.backend === 'local-folder') {
|
|
47
|
+
const localPath = managerStorage.localPath;
|
|
48
|
+
const writtenPaths = [];
|
|
49
|
+
for (const artifact of artifacts) {
|
|
50
|
+
const result = (0, local_folder_sync_1.writeLocalFolderFile)(localPath, artifact.relativePath, artifact.content);
|
|
51
|
+
if (result.skipped) {
|
|
52
|
+
throw new Error(`Skipped writing '${artifact.relativePath}' to local folder: ${result.reason} Re-sync before publishing.`);
|
|
53
|
+
}
|
|
54
|
+
writtenPaths.push(path_1.default.join(localPath, artifact.relativePath));
|
|
55
|
+
}
|
|
56
|
+
return { backend: 'local-folder', writtenPaths };
|
|
57
|
+
}
|
|
45
58
|
if (managerStorage.backend === 'fraim-cloud') {
|
|
46
59
|
const remoteUrl = opts?.remoteUrl || process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
|
|
47
60
|
const apiKey = opts?.apiKey || (0, user_config_1.readUserFraimConfig)().apiKey;
|
|
@@ -28,6 +28,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
28
28
|
const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
|
|
29
29
|
const user_config_1 = require("./user-config");
|
|
30
30
|
const git_org_sync_1 = require("./git-org-sync");
|
|
31
|
+
const local_folder_sync_1 = require("./local-folder-sync");
|
|
31
32
|
exports.ORG_CACHE_DIRNAME = 'org';
|
|
32
33
|
exports.ORG_SYNC_METADATA_FILE = '.org-sync-metadata.json';
|
|
33
34
|
exports.ORG_CACHE_MANAGED_HEADER = '<!-- FRAIM_ORG_SYNC_MANAGED_CONTENT -->';
|
|
@@ -90,7 +91,9 @@ function decorateManagedOrgFile(content, backend) {
|
|
|
90
91
|
return normalized;
|
|
91
92
|
const writePath = backend === 'git'
|
|
92
93
|
? 'open a pull request against your organization repo'
|
|
93
|
-
:
|
|
94
|
+
: backend === 'local-folder'
|
|
95
|
+
? 'edit the file in your synced folder and let the sync client upload it'
|
|
96
|
+
: 'update it through the organization-onboarding flow';
|
|
94
97
|
const marker = [
|
|
95
98
|
exports.ORG_CACHE_MANAGED_HEADER,
|
|
96
99
|
'> [!IMPORTANT]',
|
|
@@ -201,7 +204,24 @@ async function syncOrgCache(options) {
|
|
|
201
204
|
if (!organization)
|
|
202
205
|
return { status: 'disabled' };
|
|
203
206
|
}
|
|
207
|
+
// single-machine: no remote sync; content stays local. Return disabled so
|
|
208
|
+
// callers know there is nothing to refresh (issue #834).
|
|
209
|
+
if (organization.backend === 'single-machine') {
|
|
210
|
+
return { status: 'disabled' };
|
|
211
|
+
}
|
|
204
212
|
try {
|
|
213
|
+
if (organization.backend === 'local-folder') {
|
|
214
|
+
const localPath = organization.localPath;
|
|
215
|
+
const files = (0, local_folder_sync_1.collectLocalFolderFiles)(localPath, isSafePackPath);
|
|
216
|
+
const metadata = {
|
|
217
|
+
version: (0, local_folder_sync_1.localFolderVersion)(localPath),
|
|
218
|
+
backend: 'local-folder',
|
|
219
|
+
source: localPath,
|
|
220
|
+
syncedAt: new Date().toISOString(),
|
|
221
|
+
};
|
|
222
|
+
materializeCache(files, metadata);
|
|
223
|
+
return { status: 'synced', metadata };
|
|
224
|
+
}
|
|
205
225
|
if (organization.backend === 'git') {
|
|
206
226
|
const snapshot = (0, git_org_sync_1.fetchOrgRepoSnapshot)(organization.gitUrl);
|
|
207
227
|
try {
|
|
@@ -22,6 +22,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
22
22
|
const os_1 = __importDefault(require("os"));
|
|
23
23
|
const path_1 = __importDefault(require("path"));
|
|
24
24
|
const user_config_1 = require("./user-config");
|
|
25
|
+
const local_folder_sync_1 = require("./local-folder-sync");
|
|
25
26
|
const PACK_RELATIVE_PATH = /^(context|rules|learnings)\/[\w.-]+\.md$/;
|
|
26
27
|
const ALLOWED_GIT_URL = /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/|[\w.-]+@[\w.-]+:)/;
|
|
27
28
|
/**
|
|
@@ -63,6 +64,18 @@ async function publishOrgArtifacts(artifacts, opts) {
|
|
|
63
64
|
throw new Error(`Invalid org-pack path '${a.relativePath}' (must be context|rules|learnings/<name>.md).`);
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
if (organization.backend === 'local-folder') {
|
|
68
|
+
const localPath = organization.localPath;
|
|
69
|
+
const writtenPaths = [];
|
|
70
|
+
for (const a of artifacts) {
|
|
71
|
+
const result = (0, local_folder_sync_1.writeLocalFolderFile)(localPath, a.relativePath, a.content);
|
|
72
|
+
if (result.skipped) {
|
|
73
|
+
throw new Error(`Skipped writing '${a.relativePath}' to local folder: ${result.reason} Re-sync before publishing.`);
|
|
74
|
+
}
|
|
75
|
+
writtenPaths.push(path_1.default.join(localPath, a.relativePath));
|
|
76
|
+
}
|
|
77
|
+
return { backend: 'local-folder', writtenPaths };
|
|
78
|
+
}
|
|
66
79
|
if (organization.backend === 'fraim-cloud') {
|
|
67
80
|
const remoteUrl = opts?.remoteUrl || process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
|
|
68
81
|
const apiKey = opts?.apiKey || (0, user_config_1.readUserFraimConfig)().apiKey;
|
|
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getLayerBackend = getLayerBackend;
|
|
7
|
+
exports.setLayerBackend = setLayerBackend;
|
|
6
8
|
exports.readUserFraimConfig = readUserFraimConfig;
|
|
7
9
|
exports.writeUserFraimConfig = writeUserFraimConfig;
|
|
8
10
|
exports.getInstalledIdes = getInstalledIdes;
|
|
@@ -19,6 +21,23 @@ exports.getManagerStorageConfig = getManagerStorageConfig;
|
|
|
19
21
|
const fs_1 = __importDefault(require("fs"));
|
|
20
22
|
const path_1 = __importDefault(require("path"));
|
|
21
23
|
const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
|
|
24
|
+
/** Resolve the backend config for a named layer, or null when unconfigured (issue #834). */
|
|
25
|
+
function getLayerBackend(layer) {
|
|
26
|
+
if (layer === 'org')
|
|
27
|
+
return getOrganizationConfig();
|
|
28
|
+
if (layer === 'manager')
|
|
29
|
+
return getManagerStorageConfig();
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/** Write the backend config for a named layer (issue #834). */
|
|
33
|
+
function setLayerBackend(layer, config) {
|
|
34
|
+
if (layer === 'org') {
|
|
35
|
+
writeUserFraimConfig({ organization: config });
|
|
36
|
+
}
|
|
37
|
+
else if (layer === 'manager') {
|
|
38
|
+
writeUserFraimConfig({ managerStorage: config });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
22
41
|
function getUserConfigPath() {
|
|
23
42
|
return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'config.json');
|
|
24
43
|
}
|
|
@@ -77,6 +96,15 @@ function getOrganizationConfig() {
|
|
|
77
96
|
if (raw.backend === 'fraim-cloud') {
|
|
78
97
|
return { backend: 'fraim-cloud', id: raw.id };
|
|
79
98
|
}
|
|
99
|
+
if (raw.backend === 'local-folder') {
|
|
100
|
+
const localPath = typeof raw.localPath === 'string' ? raw.localPath.trim() : '';
|
|
101
|
+
if (!localPath)
|
|
102
|
+
return null;
|
|
103
|
+
return { backend: 'local-folder', localPath };
|
|
104
|
+
}
|
|
105
|
+
if (raw.backend === 'single-machine') {
|
|
106
|
+
return { backend: 'single-machine' };
|
|
107
|
+
}
|
|
80
108
|
return null;
|
|
81
109
|
}
|
|
82
110
|
/**
|
|
@@ -96,5 +124,14 @@ function getManagerStorageConfig() {
|
|
|
96
124
|
if (raw.backend === 'fraim-cloud') {
|
|
97
125
|
return { backend: 'fraim-cloud', id: raw.id };
|
|
98
126
|
}
|
|
127
|
+
if (raw.backend === 'local-folder') {
|
|
128
|
+
const localPath = typeof raw.localPath === 'string' ? raw.localPath.trim() : '';
|
|
129
|
+
if (!localPath)
|
|
130
|
+
return null;
|
|
131
|
+
return { backend: 'local-folder', localPath };
|
|
132
|
+
}
|
|
133
|
+
if (raw.backend === 'single-machine') {
|
|
134
|
+
return { backend: 'single-machine' };
|
|
135
|
+
}
|
|
99
136
|
return null;
|
|
100
137
|
}
|
|
@@ -130,7 +130,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
|
|
|
130
130
|
personaKey: 'ricardo',
|
|
131
131
|
bundleId: 'persona-ricardo-core',
|
|
132
132
|
catalogMetadata: buildCatalogMetadata('ricardo', ['role-intake-and-scorecard', 'job-posting-and-sourcing', 'candidate-pipeline-management']),
|
|
133
|
-
protectedJobs: ['role-intake-and-scorecard', 'job-posting-and-sourcing', 'candidate-pipeline-management', 'interview-coordination', 'offer-and-close-management'],
|
|
133
|
+
protectedJobs: ['role-intake-and-scorecard', 'job-posting-and-sourcing', 'candidate-pipeline-management', 'sourcing-funnel-recap', 'interview-coordination', 'offer-and-close-management'],
|
|
134
134
|
protectedAliases: ['recruiting', 'recruiter', 'hiring'],
|
|
135
135
|
defaultHireMode: 'job',
|
|
136
136
|
lockCopy: 'Hire RECardo to unlock full-cycle recruiting work for this request.'
|
|
@@ -3,9 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.AIMentor = void 0;
|
|
4
4
|
const include_resolver_1 = require("./utils/include-resolver");
|
|
5
5
|
class AIMentor {
|
|
6
|
-
constructor(resolver) {
|
|
6
|
+
constructor(resolver, skillDedup) {
|
|
7
7
|
this.jobCache = new Map();
|
|
8
8
|
this.resolver = resolver;
|
|
9
|
+
this.skillDedup = skillDedup;
|
|
9
10
|
}
|
|
10
11
|
/**
|
|
11
12
|
* Handle mentoring/coaching request from agent
|
|
@@ -50,7 +51,7 @@ class AIMentor {
|
|
|
50
51
|
return null;
|
|
51
52
|
}
|
|
52
53
|
async resolveIncludes(content, basePath) {
|
|
53
|
-
return (0, include_resolver_1.resolveIncludes)(content, this.resolver, basePath);
|
|
54
|
+
return (0, include_resolver_1.resolveIncludes)(content, this.resolver, basePath, this.skillDedup);
|
|
54
55
|
}
|
|
55
56
|
assertNoUnresolvedIncludes(content, context) {
|
|
56
57
|
const matches = content.match(/\{\{include:[^}]+\}\}/g);
|
|
@@ -87,11 +88,6 @@ class AIMentor {
|
|
|
87
88
|
getCompactPhaseAuthority() {
|
|
88
89
|
return AIMentor.COMPACT_PHASE_AUTHORITY;
|
|
89
90
|
}
|
|
90
|
-
async prependPhaseAuthority(message, isPhased) {
|
|
91
|
-
if (!isPhased)
|
|
92
|
-
return message;
|
|
93
|
-
return `${this.getCompactPhaseAuthority()}\n\n${message}`;
|
|
94
|
-
}
|
|
95
91
|
buildPhasedJobOverview(workflow) {
|
|
96
92
|
const metadataPhases = Object.keys(workflow.metadata.phases || {});
|
|
97
93
|
const initialPhase = workflow.metadata.initialPhase || metadataPhases[0] || Array.from(workflow.phases.keys())[0] || 'starting';
|
|
@@ -143,7 +139,7 @@ class AIMentor {
|
|
|
143
139
|
this.assertNoUnresolvedIncludes(message, `${workflow.metadata.name}:${targetPhase} (starting)`);
|
|
144
140
|
}
|
|
145
141
|
return {
|
|
146
|
-
message
|
|
142
|
+
message,
|
|
147
143
|
nextPhase: targetPhase,
|
|
148
144
|
status: 'starting'
|
|
149
145
|
};
|
|
@@ -188,7 +184,7 @@ class AIMentor {
|
|
|
188
184
|
this.assertNoUnresolvedIncludes(message, `${workflow.metadata.name}:${phaseId} (complete)`);
|
|
189
185
|
}
|
|
190
186
|
return {
|
|
191
|
-
message
|
|
187
|
+
message,
|
|
192
188
|
nextPhase: nextPhaseId,
|
|
193
189
|
status: 'complete'
|
|
194
190
|
};
|
|
@@ -216,7 +212,7 @@ class AIMentor {
|
|
|
216
212
|
this.assertNoUnresolvedIncludes(message, `${workflow.metadata.name}:${targetPhaseId} (${status})`);
|
|
217
213
|
}
|
|
218
214
|
return {
|
|
219
|
-
message
|
|
215
|
+
message,
|
|
220
216
|
nextPhase: targetPhaseId,
|
|
221
217
|
status
|
|
222
218
|
};
|
|
@@ -163,6 +163,22 @@ exports.FRAIM_CONFIG_SCHEMA = {
|
|
|
163
163
|
"apiKey": {
|
|
164
164
|
"kind": "string"
|
|
165
165
|
},
|
|
166
|
+
"projectStorage": {
|
|
167
|
+
"kind": "object",
|
|
168
|
+
"properties": {
|
|
169
|
+
"backend": {
|
|
170
|
+
"kind": "enum",
|
|
171
|
+
"values": [
|
|
172
|
+
"git",
|
|
173
|
+
"single-machine"
|
|
174
|
+
],
|
|
175
|
+
"required": true
|
|
176
|
+
},
|
|
177
|
+
"gitUrl": {
|
|
178
|
+
"kind": "string"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
166
182
|
"competitors": {
|
|
167
183
|
"kind": "record",
|
|
168
184
|
"value": {
|
|
@@ -619,6 +635,9 @@ exports.SUPPORTED_FRAIM_CONFIG_PATHS = [
|
|
|
619
635
|
"customizations.postCleanupHook",
|
|
620
636
|
"remoteUrl",
|
|
621
637
|
"apiKey",
|
|
638
|
+
"projectStorage",
|
|
639
|
+
"projectStorage.backend",
|
|
640
|
+
"projectStorage.gitUrl",
|
|
622
641
|
"competitors",
|
|
623
642
|
"compliance",
|
|
624
643
|
"compliance.regulations",
|
|
@@ -51,9 +51,11 @@ function resolveIncludesWithIndex(content, fileIndex) {
|
|
|
51
51
|
*
|
|
52
52
|
* @param content - Raw content that may contain {{include:path}} directives
|
|
53
53
|
* @param resolver - RegistryResolver instance
|
|
54
|
+
* @param basePath - Path of the current file, used to resolve relative ./ includes
|
|
55
|
+
* @param dedup - Optional session-level dedup hook (issue #801)
|
|
54
56
|
* @returns Content with all resolvable includes inlined
|
|
55
57
|
*/
|
|
56
|
-
async function resolveIncludes(content, resolver, basePath) {
|
|
58
|
+
async function resolveIncludes(content, resolver, basePath, dedup) {
|
|
57
59
|
let result = content;
|
|
58
60
|
let pass = 0;
|
|
59
61
|
while (result.includes('{{include:') && pass < exports.MAX_INCLUDE_PASSES) {
|
|
@@ -70,13 +72,24 @@ async function resolveIncludes(content, resolver, basePath) {
|
|
|
70
72
|
const dir = basePath.includes('/') ? basePath.substring(0, basePath.lastIndexOf('/')) : '';
|
|
71
73
|
targetPath = dir ? `${dir}/${filePath.substring(2)}` : filePath.substring(2);
|
|
72
74
|
}
|
|
75
|
+
// Session-level dedup (issue #801): if this target's full content was
|
|
76
|
+
// already emitted earlier in the session, substitute a backreference
|
|
77
|
+
// rather than fetching and inlining it again.
|
|
78
|
+
if (dedup && dedup.isAlreadyEmitted(targetPath)) {
|
|
79
|
+
result = result.split(match).join(dedup.backreference(targetPath));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
73
82
|
try {
|
|
74
83
|
const fileContent = await resolver.getFile(targetPath);
|
|
75
84
|
if (fileContent !== null) {
|
|
76
85
|
// Recursively resolve includes in the newly fetched content
|
|
77
|
-
const resolvedContent = await resolveIncludes(fileContent, resolver, targetPath);
|
|
86
|
+
const resolvedContent = await resolveIncludes(fileContent, resolver, targetPath, dedup);
|
|
78
87
|
// Replace all occurrences of this specific include
|
|
79
88
|
result = result.split(match).join(resolvedContent);
|
|
89
|
+
// Mark as emitted only after a successful full inline, so a failed
|
|
90
|
+
// fetch never causes a later occurrence to be silently backreferenced.
|
|
91
|
+
if (dedup)
|
|
92
|
+
dedup.recordEmitted(targetPath);
|
|
80
93
|
}
|
|
81
94
|
else {
|
|
82
95
|
console.warn(`⚠️ Include file not found via resolver: ${targetPath} (original: ${filePath}, base: ${basePath})`);
|
|
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.FIRST_RUN_ROW_IDS = exports.FirstRunSessionService = void 0;
|
|
40
|
+
exports.buildFirstRunHubLaunchArgs = buildFirstRunHubLaunchArgs;
|
|
40
41
|
const fs_1 = __importDefault(require("fs"));
|
|
41
42
|
const os_1 = __importDefault(require("os"));
|
|
42
43
|
const path_1 = __importDefault(require("path"));
|
|
@@ -48,7 +49,6 @@ const auto_mcp_setup_1 = require("../cli/setup/auto-mcp-setup");
|
|
|
48
49
|
const setup_1 = require("../cli/commands/setup");
|
|
49
50
|
const script_sync_utils_1 = require("../cli/utils/script-sync-utils");
|
|
50
51
|
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
51
|
-
const ports_1 = require("../core/utils/ports");
|
|
52
52
|
const types_1 = require("./types");
|
|
53
53
|
Object.defineProperty(exports, "FIRST_RUN_ROW_IDS", { enumerable: true, get: function () { return types_1.FIRST_RUN_ROW_IDS; } });
|
|
54
54
|
const install_state_1 = require("./install-state");
|
|
@@ -213,6 +213,16 @@ function surfaceForAgent(option) {
|
|
|
213
213
|
status: 'configured',
|
|
214
214
|
};
|
|
215
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Issue #866 R1/R2: arguments for launching the FRAIM Hub from first-run.
|
|
218
|
+
* The Hub opens as the Electron desktop app — `fraim-hub` launches the desktop
|
|
219
|
+
* shell whenever `--browser` is absent. We omit `--project-path` so the desktop
|
|
220
|
+
* Hub opens with no active project (the invocation directory is not a project);
|
|
221
|
+
* the user opens or creates a project inside the Hub.
|
|
222
|
+
*/
|
|
223
|
+
function buildFirstRunHubLaunchArgs() {
|
|
224
|
+
return ['-y', 'fraim-hub@latest'];
|
|
225
|
+
}
|
|
216
226
|
class FirstRunSessionService {
|
|
217
227
|
constructor(options) {
|
|
218
228
|
// Issue #646: the key may be absent when first-run is launched by the macOS
|
|
@@ -933,7 +943,7 @@ class FirstRunSessionService {
|
|
|
933
943
|
async openHub() {
|
|
934
944
|
if (this.fakeMode) {
|
|
935
945
|
// Tests don't actually want a Hub server running — just confirm intent.
|
|
936
|
-
return { ok: true, message: 'Fake-mode Hub open requested.'
|
|
946
|
+
return { ok: true, desktop: true, message: 'Fake-mode Hub open requested.' };
|
|
937
947
|
}
|
|
938
948
|
// Hub launches a real CLI process, so folder-only config surfaces are not
|
|
939
949
|
// enough here. Require a runnable command in the managed or ambient PATH.
|
|
@@ -947,18 +957,13 @@ class FirstRunSessionService {
|
|
|
947
957
|
};
|
|
948
958
|
}
|
|
949
959
|
try {
|
|
950
|
-
const port = await (0, ports_1.findAvailablePort)(43091);
|
|
951
960
|
const npxBinary = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
String(port),
|
|
959
|
-
'--project-path',
|
|
960
|
-
projectPath,
|
|
961
|
-
], {
|
|
961
|
+
// Issue #866 R1/R2: launch the FRAIM Hub desktop (Electron) app, not a
|
|
962
|
+
// browser tab. `fraim-hub` opens the desktop shell whenever `--browser` is
|
|
963
|
+
// absent, and picks its own port + window — so there is no browser URL for
|
|
964
|
+
// the first-run tab to navigate to. We also omit `--project-path` so the
|
|
965
|
+
// Hub opens with no active project (the launch directory is not a project).
|
|
966
|
+
const child = (0, child_process_1.spawn)(npxBinary, buildFirstRunHubLaunchArgs(), {
|
|
962
967
|
detached: true,
|
|
963
968
|
stdio: 'ignore',
|
|
964
969
|
});
|
|
@@ -966,12 +971,11 @@ class FirstRunSessionService {
|
|
|
966
971
|
appendInstallLog(`hub-open-spawn-error ${spawnError instanceof Error ? spawnError.message : String(spawnError)}`);
|
|
967
972
|
});
|
|
968
973
|
child.unref();
|
|
969
|
-
|
|
970
|
-
appendInstallLog(`hub-opened ${hubUrl}`);
|
|
974
|
+
appendInstallLog('hub-opened desktop');
|
|
971
975
|
return {
|
|
972
976
|
ok: true,
|
|
973
|
-
|
|
974
|
-
|
|
977
|
+
desktop: true,
|
|
978
|
+
message: 'FRAIM Hub is opening in the desktop app. You can close this tab. From now on, run `npx fraim-hub@latest` to launch it again.',
|
|
975
979
|
};
|
|
976
980
|
}
|
|
977
981
|
catch (error) {
|
|
@@ -979,7 +983,7 @@ class FirstRunSessionService {
|
|
|
979
983
|
appendInstallLog(`hub-open-failed ${detail}`);
|
|
980
984
|
return {
|
|
981
985
|
ok: false,
|
|
982
|
-
message: `Could not open the Hub automatically: ${detail}. Run \`npx fraim-hub@latest
|
|
986
|
+
message: `Could not open the Hub automatically: ${detail}. Run \`npx fraim-hub@latest\` from a terminal to open it manually.`,
|
|
983
987
|
};
|
|
984
988
|
}
|
|
985
989
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* workspace root on the user's machine.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.CATEGORY_TO_FILETYPE = exports.LEARNING_PRIORITIES = void 0;
|
|
9
|
+
exports.CATEGORY_TO_FILETYPE = exports.BRAIN_LEARNING_FILETYPE_TO_REGION = exports.LEARNING_PRIORITIES = void 0;
|
|
10
10
|
exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
|
|
11
11
|
exports.computeEffectiveScore = computeEffectiveScore;
|
|
12
12
|
exports.buildLearningContextSection = buildLearningContextSection;
|
|
@@ -17,6 +17,7 @@ exports.resolveTeamContextFile = resolveTeamContextFile;
|
|
|
17
17
|
exports.readOrgBrand = readOrgBrand;
|
|
18
18
|
exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
|
|
19
19
|
exports.countPreservedLearnings = countPreservedLearnings;
|
|
20
|
+
exports.collectBrainLearningDots = collectBrainLearningDots;
|
|
20
21
|
exports.readPreservedLearnings = readPreservedLearnings;
|
|
21
22
|
exports.applyLearningEntryChange = applyLearningEntryChange;
|
|
22
23
|
exports.isTruthyFlag = isTruthyFlag;
|
|
@@ -385,11 +386,14 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
|
385
386
|
coach: { ft: 'manager-coaching', gated: false, label: '(manager-facing; all entries)' },
|
|
386
387
|
validated: { ft: 'validated-patterns', gated: true, label: '(entries above score threshold)' },
|
|
387
388
|
};
|
|
388
|
-
// Domain axis (issue #806): startup
|
|
389
|
-
//
|
|
390
|
-
// files below the global files.
|
|
391
|
-
const
|
|
392
|
-
const
|
|
389
|
+
// Domain axis (issue #806): session startup stays broad enough for stable
|
|
390
|
+
// preferences plus L0, while job contexts load the full global set and may
|
|
391
|
+
// append the current job's domain files below the global files.
|
|
392
|
+
const sessionCats = ['pref'];
|
|
393
|
+
const jobL2Cats = ['mistake', 'pref', 'coach', 'validated'];
|
|
394
|
+
const jobL1Cats = ['pref', 'coach', 'mistake', 'validated'];
|
|
395
|
+
const l2Cats = forJob ? jobL2Cats : sessionCats;
|
|
396
|
+
const l1Cats = forJob ? jobL1Cats : sessionCats;
|
|
393
397
|
const activeDomain = forJob && domain ? domain : null;
|
|
394
398
|
const dormantOf = (meta, filePath) => meta.gated ? scanMistakePatternFile(filePath, threshold, meta.ft).dormant : 0;
|
|
395
399
|
// Resolve one tier (L2 org or L1 personal) into ordered global + domain files.
|
|
@@ -473,14 +477,14 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
|
473
477
|
const coachPresent = l1.coachPresent || l2.coachPresent;
|
|
474
478
|
if (forJob) {
|
|
475
479
|
if (hasL2 || hasL1) {
|
|
476
|
-
section += '
|
|
480
|
+
section += 'Read the listed synthesized learning files before continuing, then apply the relevant patterns and preferences in this job.\n';
|
|
477
481
|
if (coachPresent) {
|
|
478
482
|
section += 'Treat manager-coaching as feedback for how the manager should continue or improve managing AI, not as agent instruction.\n';
|
|
479
483
|
}
|
|
480
484
|
}
|
|
481
485
|
}
|
|
482
|
-
else {
|
|
483
|
-
section += '
|
|
486
|
+
else if (hasL2 || hasL1) {
|
|
487
|
+
section += 'Read the listed synthesized learning files before continuing, then use this synthesized learning context throughout the session.\n';
|
|
484
488
|
if (coachPresent) {
|
|
485
489
|
section += 'Manager-coaching entries are manager-facing feedback, not instructions for the AI to follow.\n';
|
|
486
490
|
}
|
|
@@ -840,6 +844,127 @@ function countPreservedLearnings(workspaceRoot, userId) {
|
|
|
840
844
|
rawSignals,
|
|
841
845
|
};
|
|
842
846
|
}
|
|
847
|
+
// ── Issue #833: Brain learning-dot projection ──────────────────────────────
|
|
848
|
+
// Personal learning signals the FRAIM brain overlays on the SVG. Pending L0
|
|
849
|
+
// signals (raw coaching moments + unsynthesized retros) cluster in the
|
|
850
|
+
// temporal-memory region; processed L1/L2 learnings map to a fixed brain region
|
|
851
|
+
// per learning type (the approved Option A mapping). A read projection over the
|
|
852
|
+
// same files buildLearningContextSection injects — no new storage.
|
|
853
|
+
/** Fixed learning-type → brain-region mapping (issue #833, approved spec). */
|
|
854
|
+
exports.BRAIN_LEARNING_FILETYPE_TO_REGION = {
|
|
855
|
+
'mistake-patterns': 'prefrontal-cortex', // rule following / constraint enforcement
|
|
856
|
+
'preferences': 'right-parietal-experience', // experience / audience fit
|
|
857
|
+
'validated-patterns': 'left-parietal-analysis', // engineering / evaluation judgment
|
|
858
|
+
'manager-coaching': 'right-frontal-strategy', // strategy / framing (reverse mentoring)
|
|
859
|
+
};
|
|
860
|
+
const BRAIN_LEARNING_FILETYPE_LABEL = {
|
|
861
|
+
'mistake-patterns': 'Mistake pattern',
|
|
862
|
+
'preferences': 'Preference',
|
|
863
|
+
'validated-patterns': 'Validated pattern',
|
|
864
|
+
'manager-coaching': 'Manager coaching',
|
|
865
|
+
};
|
|
866
|
+
const PENDING_KIND_LABEL = {
|
|
867
|
+
'coaching-moment': 'Coaching moment',
|
|
868
|
+
'retrospective': 'Retrospective',
|
|
869
|
+
};
|
|
870
|
+
/** Filename infix that separates hot (.md) from cold-tier (.cold.md) learning files (#861). */
|
|
871
|
+
const COLD_FILE_SUFFIX = '.cold';
|
|
872
|
+
// Parse `## [P-…] Title` entries out of a learning file WITH their effective
|
|
873
|
+
// score + last-seen (unlike parseLearningEntries, which drops that bookkeeping
|
|
874
|
+
// for the Hub text display). Used only by the brain-dot projection.
|
|
875
|
+
function parseBrainLearningEntries(filePath, fileType) {
|
|
876
|
+
if (!(0, fs_1.existsSync)(filePath))
|
|
877
|
+
return [];
|
|
878
|
+
let content;
|
|
879
|
+
try {
|
|
880
|
+
content = (0, fs_1.readFileSync)(filePath, 'utf8');
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
return [];
|
|
884
|
+
}
|
|
885
|
+
const out = [];
|
|
886
|
+
const headingRe = learningEntryHeadingRegex();
|
|
887
|
+
const now = new Date();
|
|
888
|
+
let current = null;
|
|
889
|
+
const flush = () => {
|
|
890
|
+
if (!current)
|
|
891
|
+
return;
|
|
892
|
+
const rawScore = current.lastSeen
|
|
893
|
+
? computeEffectiveScore(current.severity, current.lastSeen, current.recurrences, fileType, now)
|
|
894
|
+
: null;
|
|
895
|
+
out.push({
|
|
896
|
+
title: current.title,
|
|
897
|
+
severity: current.severity,
|
|
898
|
+
score: rawScore === null ? null : Math.round(rawScore * 10) / 10,
|
|
899
|
+
lastSeen: current.lastSeen || null,
|
|
900
|
+
});
|
|
901
|
+
current = null;
|
|
902
|
+
};
|
|
903
|
+
for (const line of content.split(/\r?\n/)) {
|
|
904
|
+
const header = line.match(headingRe);
|
|
905
|
+
if (header) {
|
|
906
|
+
flush();
|
|
907
|
+
current = { severity: header[1], title: header[2].trim(), lastSeen: '', recurrences: 1 };
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (!current)
|
|
911
|
+
continue;
|
|
912
|
+
const lastSeenMatch = line.match(/^\*\*Last seen\*\*:\s*(.+)/);
|
|
913
|
+
if (lastSeenMatch) {
|
|
914
|
+
current.lastSeen = lastSeenMatch[1].trim();
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
const recurrenceMatch = line.match(/^\*\*Recurrences\*\*:\s*(\d+)/);
|
|
918
|
+
if (recurrenceMatch) {
|
|
919
|
+
current.recurrences = parseInt(recurrenceMatch[1], 10);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
flush();
|
|
923
|
+
return out;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Build the brain learning-dot projection (issue #833) for one user: pending L0
|
|
927
|
+
* signals + processed L1 (personal) and L2 (org) learnings grouped by file type
|
|
928
|
+
* and mapped to a fixed brain region. Reuses the same root/user resolution as
|
|
929
|
+
* buildLearningContextSection so the dots line up with the auto-loaded context.
|
|
930
|
+
*/
|
|
931
|
+
function collectBrainLearningDots(workspaceRoot, userId) {
|
|
932
|
+
const roots = getLearningRoots(workspaceRoot);
|
|
933
|
+
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
934
|
+
const pendingSignals = collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots).map((s) => ({
|
|
935
|
+
type: s.kind,
|
|
936
|
+
typeLabel: PENDING_KIND_LABEL[s.kind],
|
|
937
|
+
fileName: s.displayPath.split('/').pop() || s.displayPath,
|
|
938
|
+
displayPath: s.displayPath,
|
|
939
|
+
}));
|
|
940
|
+
const resolvePersonal = (fileName) => resolvePersonalLearningFile(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
|
|
941
|
+
const processed = [];
|
|
942
|
+
const fileTypes = ['mistake-patterns', 'preferences', 'validated-patterns', 'manager-coaching'];
|
|
943
|
+
for (const fileType of fileTypes) {
|
|
944
|
+
const region = exports.BRAIN_LEARNING_FILETYPE_TO_REGION[fileType];
|
|
945
|
+
const typeLabel = BRAIN_LEARNING_FILETYPE_LABEL[fileType];
|
|
946
|
+
// L1 personal (hot file)
|
|
947
|
+
const personal = resolvePersonal(`${resolvedUserId}-${fileType}.md`);
|
|
948
|
+
const personalEntries = parseBrainLearningEntries(personal.path, fileType);
|
|
949
|
+
if (personalEntries.length) {
|
|
950
|
+
processed.push({ type: fileType, typeLabel, region, scope: 'personal', displayPath: personal.displayPath, entries: personalEntries });
|
|
951
|
+
}
|
|
952
|
+
// L1 cold sibling — only emitted when the .cold.md file exists alongside the hot file
|
|
953
|
+
const coldFileName = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
|
|
954
|
+
const coldResolved = resolvePersonal(coldFileName);
|
|
955
|
+
const coldEntries = parseBrainLearningEntries(coldResolved.path, fileType);
|
|
956
|
+
if (coldEntries.length) {
|
|
957
|
+
processed.push({ type: fileType, typeLabel, region, scope: 'cold', displayPath: coldResolved.displayPath, entries: coldEntries });
|
|
958
|
+
}
|
|
959
|
+
// L2 org
|
|
960
|
+
const org = resolveOrgLearningFile(roots.repoLearningsBase, `org-${fileType}.md`);
|
|
961
|
+
const orgEntries = parseBrainLearningEntries(org.path, fileType);
|
|
962
|
+
if (orgEntries.length) {
|
|
963
|
+
processed.push({ type: fileType, typeLabel, region, scope: 'org', displayPath: org.displayPath, entries: orgEntries });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return { pending: { count: pendingSignals.length, signals: pendingSignals }, processed };
|
|
967
|
+
}
|
|
843
968
|
exports.CATEGORY_TO_FILETYPE = {
|
|
844
969
|
avoid: 'mistake-patterns',
|
|
845
970
|
preference: 'preferences',
|
|
@@ -871,7 +996,7 @@ function listDomainLearningFiles(dir, prefix, fileType) {
|
|
|
871
996
|
// Parse the `## [P-…] Title` entries (and their body prose) out of one learning
|
|
872
997
|
// file. Score/Last seen/Recurrences metadata lines are dropped — the Hub shows
|
|
873
998
|
// the human-readable learning, not the bookkeeping.
|
|
874
|
-
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global') {
|
|
999
|
+
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier) {
|
|
875
1000
|
if (!(0, fs_1.existsSync)(filePath))
|
|
876
1001
|
return [];
|
|
877
1002
|
let content;
|
|
@@ -897,7 +1022,10 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
897
1022
|
const header = line.match(headingRe);
|
|
898
1023
|
if (header) {
|
|
899
1024
|
flush();
|
|
900
|
-
|
|
1025
|
+
const entry = { severity: header[1], title: header[2].trim(), body: '', source: displayPath, category, level, domain };
|
|
1026
|
+
if (tier !== undefined)
|
|
1027
|
+
entry.tier = tier;
|
|
1028
|
+
current = entry;
|
|
901
1029
|
continue;
|
|
902
1030
|
}
|
|
903
1031
|
if (!current)
|
|
@@ -945,10 +1073,15 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
945
1073
|
for (const cat of cats) {
|
|
946
1074
|
const fileType = exports.CATEGORY_TO_FILETYPE[cat];
|
|
947
1075
|
const f = `${resolvedUserId}-${fileType}.md`;
|
|
948
|
-
out.push(...parseLearningEntries((0, path_1.join)(dir, f), `${displayBase}/${f}`, cat, level));
|
|
1076
|
+
out.push(...parseLearningEntries((0, path_1.join)(dir, f), `${displayBase}/${f}`, cat, level, 'global', 'hot'));
|
|
1077
|
+
// Cold sibling — only read when it exists alongside the hot file (#861).
|
|
1078
|
+
const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
|
|
1079
|
+
if ((0, fs_1.existsSync)((0, path_1.join)(dir, coldF))) {
|
|
1080
|
+
out.push(...parseLearningEntries((0, path_1.join)(dir, coldF), `${displayBase}/${coldF}`, cat, level, 'global', 'cold'));
|
|
1081
|
+
}
|
|
949
1082
|
// #806: domain-scoped personal learnings alongside the flat global file.
|
|
950
1083
|
for (const { fileName, domain } of listDomainLearningFiles(dir, resolvedUserId, fileType)) {
|
|
951
|
-
out.push(...parseLearningEntries((0, path_1.join)(dir, fileName), `${displayBase}/${fileName}`, cat, level, domain));
|
|
1084
|
+
out.push(...parseLearningEntries((0, path_1.join)(dir, fileName), `${displayBase}/${fileName}`, cat, level, domain, 'hot'));
|
|
952
1085
|
}
|
|
953
1086
|
}
|
|
954
1087
|
return out;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SessionSkillDedup = void 0;
|
|
4
|
+
exports.isSkillIncludePath = isSkillIncludePath;
|
|
5
|
+
/**
|
|
6
|
+
* A registry skill lives under a `skills/` path segment (e.g.
|
|
7
|
+
* `skills/communication/active-listening.md`). Only these are eligible for
|
|
8
|
+
* session dedup; delivery/orchestration/template includes are inlined normally.
|
|
9
|
+
*/
|
|
10
|
+
function isSkillIncludePath(targetPath) {
|
|
11
|
+
return /(^|\/)skills\//.test(targetPath);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Session-scoped skill-include dedup (issue #801).
|
|
15
|
+
*
|
|
16
|
+
* FRAIM jobs inline `{{include:skills/...}}` on every phase that references a
|
|
17
|
+
* skill, so a skill used in multiple phases of one job is re-inlined in full on
|
|
18
|
+
* each `seekMentoring` turn. This dedup records the first full emission of a
|
|
19
|
+
* skill in a session; later inclusions of the same skill are replaced with a
|
|
20
|
+
* short backreference that names the skill and gives a reload path. The reload
|
|
21
|
+
* path means a context compaction between phases cannot silently strip the
|
|
22
|
+
* guidance — the agent can re-fetch the skill on demand.
|
|
23
|
+
*
|
|
24
|
+
* The `emitted` set is owned by the long-lived proxy server (one per session)
|
|
25
|
+
* and passed by reference; each per-request AIMentor shares the same set. The
|
|
26
|
+
* server clears it on `fraim_connect` so a resumed/compacted session re-emits
|
|
27
|
+
* skills in full.
|
|
28
|
+
*/
|
|
29
|
+
class SessionSkillDedup {
|
|
30
|
+
constructor(emitted) {
|
|
31
|
+
this.emitted = emitted;
|
|
32
|
+
}
|
|
33
|
+
isAlreadyEmitted(targetPath) {
|
|
34
|
+
return isSkillIncludePath(targetPath) && this.emitted.has(targetPath);
|
|
35
|
+
}
|
|
36
|
+
recordEmitted(targetPath) {
|
|
37
|
+
if (isSkillIncludePath(targetPath)) {
|
|
38
|
+
this.emitted.add(targetPath);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
backreference(targetPath) {
|
|
42
|
+
const name = targetPath.replace(/^.*\//, '').replace(/\.md$/, '');
|
|
43
|
+
return `> [Skill \`${name}\` was included earlier in this session; its full text is omitted here to reduce token cost. If it is no longer in your context, reload it with \`get_fraim_file({ path: "${targetPath}" })\`.]`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.SessionSkillDedup = SessionSkillDedup;
|
|
@@ -69,6 +69,7 @@ const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
|
69
69
|
const token_adapter_registry_js_1 = require("./token-adapter-registry.js");
|
|
70
70
|
const learning_context_builder_js_1 = require("./learning-context-builder.js");
|
|
71
71
|
const learning_domains_js_1 = require("../config/learning-domains.js");
|
|
72
|
+
const skill_include_dedup_js_1 = require("./skill-include-dedup.js");
|
|
72
73
|
/**
|
|
73
74
|
* Handle template substitution logic separately for better testability
|
|
74
75
|
*/
|
|
@@ -437,6 +438,11 @@ class FraimLocalMCPServer {
|
|
|
437
438
|
this.jobStartCache = new Map();
|
|
438
439
|
this.connectSyncInFlight = null;
|
|
439
440
|
this.latestConnectSyncWarning = null;
|
|
441
|
+
// Issue #801: registry-path set of skills already inlined this session. Shared
|
|
442
|
+
// by reference into each per-request AIMentor so a skill referenced by multiple
|
|
443
|
+
// phases of a job is emitted in full once, then backreferenced. Cleared on
|
|
444
|
+
// fraim_connect so a resumed/compacted session re-emits skills in full.
|
|
445
|
+
this.sessionEmittedSkills = new Set();
|
|
440
446
|
this.orgCacheRefreshInFlight = false;
|
|
441
447
|
this.managerCacheRefreshInFlight = false;
|
|
442
448
|
this.writer = writer || process.stdout.write.bind(process.stdout);
|
|
@@ -1334,6 +1340,10 @@ class FraimLocalMCPServer {
|
|
|
1334
1340
|
finalizedResponse = await this.resolveIncludesInResponse(finalizedResponse, requestSessionId, requestId);
|
|
1335
1341
|
// 3. After fraim_connect succeeds, capture user email and inject learning context.
|
|
1336
1342
|
if (toolName === 'fraim_connect' && !finalizedResponse.error) {
|
|
1343
|
+
// Issue #801: a (re)connect starts/resumes a session. Reset the skill-dedup
|
|
1344
|
+
// set so skills are re-emitted in full — the agent's earlier context may
|
|
1345
|
+
// have been compacted away, so backreferencing it would be unsafe.
|
|
1346
|
+
this.sessionEmittedSkills.clear();
|
|
1337
1347
|
const text = finalizedResponse.result?.content?.[0]?.text;
|
|
1338
1348
|
if (typeof text === 'string') {
|
|
1339
1349
|
let responseText = text;
|
|
@@ -1738,7 +1748,17 @@ class FraimLocalMCPServer {
|
|
|
1738
1748
|
*/
|
|
1739
1749
|
getMentor(requestSessionId) {
|
|
1740
1750
|
const resolver = this.getRegistryResolver(requestSessionId);
|
|
1741
|
-
return new ai_mentor_1.AIMentor(resolver);
|
|
1751
|
+
return new ai_mentor_1.AIMentor(resolver, this.buildSkillDedup());
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Build the session-level skill-include dedup (issue #801). Returns undefined
|
|
1755
|
+
* when disabled via FRAIM_DISABLE_SKILL_CACHE, so includes inline in full
|
|
1756
|
+
* exactly as before (no behavior change on the kill switch).
|
|
1757
|
+
*/
|
|
1758
|
+
buildSkillDedup() {
|
|
1759
|
+
if (process.env.FRAIM_DISABLE_SKILL_CACHE === '1')
|
|
1760
|
+
return undefined;
|
|
1761
|
+
return new skill_include_dedup_js_1.SessionSkillDedup(this.sessionEmittedSkills);
|
|
1742
1762
|
}
|
|
1743
1763
|
normalizeRepoContext(repo) {
|
|
1744
1764
|
if (!repo || typeof repo !== 'object')
|
|
@@ -61,6 +61,17 @@ class AuthMiddleware {
|
|
|
61
61
|
const sessionFromCookie = (0, cookie_service_1.readSessionCookie)(req);
|
|
62
62
|
const sessionFromBearer = (0, cookie_service_1.readBearerToken)(req);
|
|
63
63
|
const sessionToken = sessionFromCookie || sessionFromBearer;
|
|
64
|
+
const isMcpHandshake = req.method === 'POST' &&
|
|
65
|
+
p === '/mcp' &&
|
|
66
|
+
req.body?.jsonrpc === '2.0' &&
|
|
67
|
+
(req.body?.method === 'initialize' || req.body?.method === 'ping');
|
|
68
|
+
// Some hosted MCP clients, including Microsoft 365 Copilot's agent-tools
|
|
69
|
+
// runtime, perform initialize/ping before attaching OAuth credentials.
|
|
70
|
+
// Allow only those non-mutating handshake methods through; tool discovery
|
|
71
|
+
// and tool calls still require the normal authenticated path below.
|
|
72
|
+
if (!apiKey && !sessionToken && isMcpHandshake) {
|
|
73
|
+
return next();
|
|
74
|
+
}
|
|
64
75
|
// If a session token is present and there's no API key, resolve it.
|
|
65
76
|
if (!apiKey && sessionToken) {
|
|
66
77
|
try {
|
|
@@ -31,6 +31,7 @@ class TelemetryMiddleware {
|
|
|
31
31
|
const exemptMethods = [
|
|
32
32
|
'initialize',
|
|
33
33
|
'notifications/initialized',
|
|
34
|
+
'ping',
|
|
34
35
|
'tools/list',
|
|
35
36
|
'resources/list',
|
|
36
37
|
'prompts/list',
|
|
@@ -63,7 +64,7 @@ class TelemetryMiddleware {
|
|
|
63
64
|
// session-active gate is exempted.
|
|
64
65
|
// Issue #701/#744 - hosted AI Hub HTTP APIs are browser/gateway routes,
|
|
65
66
|
// not MCP tool calls. They are still authenticated by API key/session.
|
|
66
|
-
const exemptPaths = ['/health', '/admin', '/api/registry/sync', '/api/registry/github-workflows', '/api/registry/topology/personalized', '/api/registry/topology/usage-heatmap', '/api/org/', '/api/manager/', '/api/ai-hub/', '/api/payment/', '/api/sales/', '/api/signup', '/api/providers', '/api/analytics', '/analytics', '/api/auth/', '/api/account/'];
|
|
67
|
+
const exemptPaths = ['/health', '/admin', '/api/registry/sync', '/api/registry/github-workflows', '/api/registry/topology/personalized', '/api/registry/topology/usage-heatmap', '/api/registry/topology/learnings-dots', '/api/org/', '/api/manager/', '/api/ai-hub/', '/api/payment/', '/api/sales/', '/api/signup', '/api/providers', '/api/analytics', '/analytics', '/api/auth/', '/api/account/'];
|
|
67
68
|
if (exemptPaths.some(p => req.path.startsWith(p))) {
|
|
68
69
|
return next();
|
|
69
70
|
}
|
|
@@ -139,6 +139,9 @@ class McpService {
|
|
|
139
139
|
else if (method === 'tools/call') {
|
|
140
140
|
result = await this.handleToolCall(params, context);
|
|
141
141
|
}
|
|
142
|
+
else if (method === 'ping') {
|
|
143
|
+
result = {};
|
|
144
|
+
}
|
|
142
145
|
else if (method === 'notifications/initialized') {
|
|
143
146
|
// Notifications don't need a response
|
|
144
147
|
return null;
|
|
@@ -454,7 +457,14 @@ class McpService {
|
|
|
454
457
|
content: [{
|
|
455
458
|
type: 'text',
|
|
456
459
|
text: response
|
|
457
|
-
}]
|
|
460
|
+
}],
|
|
461
|
+
structuredContent: {
|
|
462
|
+
jobs: matches.map(job => ({
|
|
463
|
+
name: job.name,
|
|
464
|
+
description: job.description,
|
|
465
|
+
category: job.category
|
|
466
|
+
}))
|
|
467
|
+
}
|
|
458
468
|
};
|
|
459
469
|
}
|
|
460
470
|
async handleListFiles(type, category) {
|
|
@@ -572,6 +582,13 @@ class McpService {
|
|
|
572
582
|
type: 'text',
|
|
573
583
|
text: lines.join('\n')
|
|
574
584
|
}],
|
|
585
|
+
structuredContent: {
|
|
586
|
+
sessionId,
|
|
587
|
+
userId: session.userId,
|
|
588
|
+
agent: session.agent,
|
|
589
|
+
machine: session.machine,
|
|
590
|
+
repo: session.repo
|
|
591
|
+
},
|
|
575
592
|
sessionId: sessionId
|
|
576
593
|
};
|
|
577
594
|
}
|
package/package.json
CHANGED
|
@@ -466,7 +466,7 @@
|
|
|
466
466
|
try { await api('/api/first-run/set-preference', 'POST', { choice: 'hub' }); } catch (_) {}
|
|
467
467
|
try {
|
|
468
468
|
hubBtn.disabled = true;
|
|
469
|
-
setStatus('Opening Hub...');
|
|
469
|
+
setStatus('Opening the FRAIM Hub desktop app...');
|
|
470
470
|
const openResp = await api('/api/first-run/open-hub', 'POST');
|
|
471
471
|
if (openResp && openResp.needsAgentSetup) {
|
|
472
472
|
state.activeStep = 'agents';
|
|
@@ -474,7 +474,10 @@
|
|
|
474
474
|
setStatus(openResp.message, 'error');
|
|
475
475
|
return;
|
|
476
476
|
}
|
|
477
|
-
|
|
477
|
+
// Issue #866 R1: the Hub opens as the Electron desktop app, so the
|
|
478
|
+
// first-run tab does not navigate itself to a browser Hub. Confirm and
|
|
479
|
+
// let the user close this tab.
|
|
480
|
+
setStatus((openResp && openResp.message) || 'FRAIM Hub is opening in the desktop app. You can close this tab.');
|
|
478
481
|
} catch (err) {
|
|
479
482
|
hubBtn.disabled = false;
|
|
480
483
|
setStatus(err.message, 'error');
|