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.
@@ -374,12 +374,13 @@ const runSetup = async (options) => {
374
374
  }
375
375
  }
376
376
  else {
377
- // Hub path: launch the Hub with firstRun=true and let the user pick a project there.
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(`\n Hub is open at ${result.hubUrl}`));
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
- : 'publish the change through your FRAIM manager storage flow';
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
- : 'update it through the organization-onboarding flow';
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: await this.prependPhaseAuthority(message, true),
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: await this.prependPhaseAuthority(message, true),
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: await this.prependPhaseAuthority(message, true),
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.', hubUrl: 'http://127.0.0.1:0/ai-hub/' };
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
- const projectPath = this.state.workspacePath || process.cwd();
953
- const child = (0, child_process_1.spawn)(npxBinary, [
954
- '-y',
955
- 'fraim-hub@latest',
956
- '--browser',
957
- '--port',
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
- const hubUrl = `http://127.0.0.1:${port}/ai-hub/`;
970
- appendInstallLog(`hub-opened ${hubUrl}`);
974
+ appendInstallLog('hub-opened desktop');
971
975
  return {
972
976
  ok: true,
973
- message: `Hub is opening at ${hubUrl}. From now on, run \`npx fraim-hub@latest --browser\` to launch it again.`,
974
- hubUrl,
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 --browser\` from a terminal to open it manually.`,
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
- current = { severity: header[1], title: header[2].trim(), body: '', source: displayPath, category, level, domain };
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 {
@@ -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',
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.212",
3
+ "version": "2.0.214",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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
- if (openResp && openResp.hubUrl) window.location.replace(openResp.hubUrl);
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');