fraim 2.0.212 → 2.0.214
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/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 +22 -5
- 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 +1 -0
- 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
|
}
|
|
@@ -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
|
}
|
|
@@ -867,6 +867,8 @@ const PENDING_KIND_LABEL = {
|
|
|
867
867
|
'coaching-moment': 'Coaching moment',
|
|
868
868
|
'retrospective': 'Retrospective',
|
|
869
869
|
};
|
|
870
|
+
/** Filename infix that separates hot (.md) from cold-tier (.cold.md) learning files (#861). */
|
|
871
|
+
const COLD_FILE_SUFFIX = '.cold';
|
|
870
872
|
// Parse `## [P-…] Title` entries out of a learning file WITH their effective
|
|
871
873
|
// score + last-seen (unlike parseLearningEntries, which drops that bookkeeping
|
|
872
874
|
// for the Hub text display). Used only by the brain-dot projection.
|
|
@@ -941,12 +943,19 @@ function collectBrainLearningDots(workspaceRoot, userId) {
|
|
|
941
943
|
for (const fileType of fileTypes) {
|
|
942
944
|
const region = exports.BRAIN_LEARNING_FILETYPE_TO_REGION[fileType];
|
|
943
945
|
const typeLabel = BRAIN_LEARNING_FILETYPE_LABEL[fileType];
|
|
944
|
-
// L1 personal
|
|
946
|
+
// L1 personal (hot file)
|
|
945
947
|
const personal = resolvePersonal(`${resolvedUserId}-${fileType}.md`);
|
|
946
948
|
const personalEntries = parseBrainLearningEntries(personal.path, fileType);
|
|
947
949
|
if (personalEntries.length) {
|
|
948
950
|
processed.push({ type: fileType, typeLabel, region, scope: 'personal', displayPath: personal.displayPath, entries: personalEntries });
|
|
949
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
|
+
}
|
|
950
959
|
// L2 org
|
|
951
960
|
const org = resolveOrgLearningFile(roots.repoLearningsBase, `org-${fileType}.md`);
|
|
952
961
|
const orgEntries = parseBrainLearningEntries(org.path, fileType);
|
|
@@ -987,7 +996,7 @@ function listDomainLearningFiles(dir, prefix, fileType) {
|
|
|
987
996
|
// Parse the `## [P-…] Title` entries (and their body prose) out of one learning
|
|
988
997
|
// file. Score/Last seen/Recurrences metadata lines are dropped — the Hub shows
|
|
989
998
|
// the human-readable learning, not the bookkeeping.
|
|
990
|
-
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global') {
|
|
999
|
+
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier) {
|
|
991
1000
|
if (!(0, fs_1.existsSync)(filePath))
|
|
992
1001
|
return [];
|
|
993
1002
|
let content;
|
|
@@ -1013,7 +1022,10 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1013
1022
|
const header = line.match(headingRe);
|
|
1014
1023
|
if (header) {
|
|
1015
1024
|
flush();
|
|
1016
|
-
|
|
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;
|
|
1017
1029
|
continue;
|
|
1018
1030
|
}
|
|
1019
1031
|
if (!current)
|
|
@@ -1061,10 +1073,15 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1061
1073
|
for (const cat of cats) {
|
|
1062
1074
|
const fileType = exports.CATEGORY_TO_FILETYPE[cat];
|
|
1063
1075
|
const f = `${resolvedUserId}-${fileType}.md`;
|
|
1064
|
-
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
|
+
}
|
|
1065
1082
|
// #806: domain-scoped personal learnings alongside the flat global file.
|
|
1066
1083
|
for (const { fileName, domain } of listDomainLearningFiles(dir, resolvedUserId, fileType)) {
|
|
1067
|
-
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'));
|
|
1068
1085
|
}
|
|
1069
1086
|
}
|
|
1070
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 {
|
|
@@ -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');
|