fraim-hub 2.0.208 → 2.0.209

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.
@@ -686,12 +686,22 @@ function parseFraimInvocation(message) {
686
686
  const trimmed = message.trim();
687
687
  if (!trimmed)
688
688
  return null;
689
- const match = trimmed.match(/^[$/@]fraim(?:\s+(\S+))?\s*([\s\S]*)$/);
689
+ const match = trimmed.match(/^([$\/@]fraim)([\s\S]*)$/);
690
690
  if (!match)
691
691
  return null;
692
+ const rest = match[2] || '';
693
+ if (rest && !/^\s/.test(rest))
694
+ return null;
695
+ const sameLineJobMatch = rest.match(/^[ \t]+(\S+)([\s\S]*)$/);
696
+ if (sameLineJobMatch) {
697
+ return {
698
+ jobId: sameLineJobMatch[1],
699
+ remainder: (sameLineJobMatch[2] || '').trim(),
700
+ };
701
+ }
692
702
  return {
693
- jobId: match[1] || null,
694
- remainder: (match[2] || '').trim(),
703
+ jobId: null,
704
+ remainder: rest.trim(),
695
705
  };
696
706
  }
697
707
  // Rewrite UI-facing /fraim or $fraim invocations into direct MCP tool
@@ -994,10 +1004,10 @@ function ensureEmptyMcpConfig() {
994
1004
  // --append-system-prompt alone doesn't block MCP tools; --strict-mcp-config
995
1005
  // alone doesn't suppress CLAUDE.md's "scan job stubs" instruction.
996
1006
  const DIRECT_SYSTEM_PROMPT_OVERRIDE = 'DO NOT USE FRAIM FOR THIS SESSION. ' +
997
- 'You are operating in Direct mode for an A/B comparison — no FRAIM, no phases, no seekMentoring, ' +
998
- 'no job stubs, no structured workflow. Ignore all CLAUDE.md instructions to scan fraim/ or call get_fraim_job. ' +
1007
+ 'You are operating in Direct mode for an A/B comparison — no FRAIM, no phases, ' +
1008
+ 'no job stubs, no structured workflow. Ignore all repository instructions to scan fraim/ or load FRAIM jobs. ' +
999
1009
  'Answer the user directly and conversationally.';
1000
- const DIRECT_PREAMBLE = 'DO NOT USE FRAIM FOR THIS SESSION. No phases, no seekMentoring, no structured workflow.\n\n';
1010
+ const DIRECT_PREAMBLE = 'DO NOT USE FRAIM FOR THIS SESSION. No phases, no FRAIM workflow tools, no structured workflow.\n\n';
1001
1011
  // Issue #442: builds a CLI plan for the Direct (B) side of an A/B run.
1002
1012
  // All agents supported: Codex and Gemini run raw (no FRAIM preamble);
1003
1013
  // Claude uses --strict-mcp-config + --append-system-prompt for full isolation.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.extractExplicitFraimInvocation = extractExplicitFraimInvocation;
4
4
  exports.fraimInvocationFor = fraimInvocationFor;
5
+ exports.fraimDirectiveFor = fraimDirectiveFor;
5
6
  exports.buildCommunicationStyleNote = buildCommunicationStyleNote;
6
7
  exports.buildSameJobContinueMessage = buildSameJobContinueMessage;
7
8
  exports.buildManagerMessage = buildManagerMessage;
@@ -25,6 +26,9 @@ function fraimInvocationFor(employeeId, jobId) {
25
26
  const symbol = employeeId === 'codex' ? '$fraim' : '/fraim';
26
27
  return `${symbol} ${jobId}`;
27
28
  }
29
+ function fraimDirectiveFor(employeeId) {
30
+ return employeeId === 'codex' ? '$fraim' : '/fraim';
31
+ }
28
32
  // #521: a Hub-injected communication-style note so the employee's messages to the
29
33
  // manager stay about the JOB and its outcomes — not the FRAIM machinery (seeking
30
34
  // mentoring, tool calls, git, phase switches). The structured signals (the
@@ -54,6 +58,11 @@ function buildManagerMessage(employeeId, jobId, kind, instructions, stubPath) {
54
58
  const explicit = extractExplicitFraimInvocation(trimmed);
55
59
  const effectiveJobId = explicit?.jobId || jobId;
56
60
  const invocation = fraimInvocationFor(employeeId, effectiveJobId);
61
+ if (!invocation && effectiveJobId === '__freeform__' && kind === 'start') {
62
+ const directive = fraimDirectiveFor(employeeId);
63
+ const remainder = explicit ? explicit.remainder : trimmed;
64
+ return remainder ? `${directive}\n\n${remainder}` : directive;
65
+ }
57
66
  if (!invocation)
58
67
  return explicit?.remainder || trimmed;
59
68
  const remainder = explicit ? explicit.remainder : trimmed;
@@ -39,18 +39,21 @@ function projectPathExists(projectPath) {
39
39
  function projectIdForPath(projectPath) {
40
40
  return `p-${(0, crypto_1.createHash)('sha1').update(canonicalProjectPath(projectPath)).digest('base64url').slice(0, 16)}`;
41
41
  }
42
- function normalizeRemovedProjectPaths(raw, currentProjectPath) {
42
+ // Issue #788: tombstones are canonicalized and de-duplicated, but NEVER dropped for
43
+ // being the current project. The "current project is always visible" rule (#719) is a
44
+ // DISPLAY concern handled in normalizeAiHubProjectList (the current entry bypasses the
45
+ // tombstone filter). The original #726 code dropped the current-project tombstone HERE,
46
+ // so load()/saveProjects()/bootstrap silently erased it from disk whenever the deleted
47
+ // folder was the open workspace — an implicit revive that resurrected the project on the
48
+ // next add. Revive must be explicit (reviveRemovedPaths only; #726 contract).
49
+ function normalizeRemovedProjectPaths(raw) {
43
50
  if (!Array.isArray(raw))
44
51
  return [];
45
- const currentKey = currentProjectPath ? canonicalProjectPath(currentProjectPath) : null;
46
52
  const seen = new Set();
47
53
  for (const value of raw) {
48
54
  if (typeof value !== 'string' || value.trim().length === 0)
49
55
  continue;
50
- const key = canonicalProjectPath(value);
51
- if (currentKey && key === currentKey)
52
- continue;
53
- seen.add(key);
56
+ seen.add(canonicalProjectPath(value));
54
57
  }
55
58
  return Array.from(seen);
56
59
  }
@@ -100,22 +103,33 @@ function normalizeProjectEntry(raw, fallbackPath) {
100
103
  };
101
104
  }
102
105
  function normalizeAiHubProjectList(projects, currentProjectPath, options = {}) {
106
+ // Issue #788: project identity is the CANONICAL path (case-insensitive on win32),
107
+ // matching both the tombstone comparison and the conversation store. De-duping by the
108
+ // case-preserving path (the old behavior) let the same folder in two casings — one from
109
+ // preferences.projects, one from a conversation bucket key — survive as two entries: the
110
+ // reported "2 instances". The map value keeps the first-seen case-preserving folderPath
111
+ // for display (the current project is added first, so its real casing wins).
103
112
  const byPath = new Map();
104
- const currentKey = currentProjectPath ? normalizeProjectPath(currentProjectPath) : null;
105
- const removedKeys = new Set(normalizeRemovedProjectPaths(options.removedProjectPaths, currentProjectPath));
106
- const add = (entry) => {
113
+ const currentCanonical = currentProjectPath ? canonicalProjectPath(currentProjectPath) : null;
114
+ const removedKeys = new Set(normalizeRemovedProjectPaths(options.removedProjectPaths));
115
+ const add = (entry, isCurrent = false) => {
107
116
  if (!entry)
108
117
  return;
109
- const key = normalizeProjectPath(entry.folderPath);
110
- if (removedKeys.has(canonicalProjectPath(key)))
118
+ const displayPath = normalizeProjectPath(entry.folderPath);
119
+ const dedupKey = canonicalProjectPath(displayPath);
120
+ // Issue #788: the current project is always shown in its own workspace, even when
121
+ // tombstoned (#719 never-empty Hub). This is a display override ONLY — the tombstone
122
+ // is neither erased here nor in persistence, so switching away re-hides the project
123
+ // and no subsequent add can resurrect it.
124
+ if (!isCurrent && removedKeys.has(dedupKey))
111
125
  return;
112
- if (!options.includeMissing && key !== currentKey && !projectPathExists(key))
126
+ if (!options.includeMissing && dedupKey !== currentCanonical && !projectPathExists(displayPath))
113
127
  return;
114
- const existing = byPath.get(key);
115
- byPath.set(key, existing ? { ...existing, ...entry, folderPath: key } : { ...entry, folderPath: key });
128
+ const existing = byPath.get(dedupKey);
129
+ byPath.set(dedupKey, existing ? { ...existing, ...entry, folderPath: existing.folderPath } : { ...entry, folderPath: displayPath });
116
130
  };
117
131
  if (currentProjectPath)
118
- add(normalizeProjectEntry({ folderPath: currentProjectPath }, currentProjectPath));
132
+ add(normalizeProjectEntry({ folderPath: currentProjectPath }, currentProjectPath), true);
119
133
  for (const project of projects)
120
134
  add(normalizeProjectEntry(project));
121
135
  return withUniqueProjectIds(Array.from(byPath.values()));
@@ -130,7 +144,7 @@ class AiHubPreferencesStore {
130
144
  }
131
145
  try {
132
146
  const raw = JSON.parse(fs_1.default.readFileSync(this.stateFilePath, 'utf8'));
133
- const removedProjectPaths = normalizeRemovedProjectPaths(raw.removedProjectPaths, projectPath);
147
+ const removedProjectPaths = normalizeRemovedProjectPaths(raw.removedProjectPaths);
134
148
  return {
135
149
  projectPath: raw.projectPath || projectPath,
136
150
  employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex' || raw.employeeId === 'gemini' || raw.employeeId === 'copilot') ? raw.employeeId : DEFAULT_EMPLOYEE,
@@ -158,8 +172,8 @@ class AiHubPreferencesStore {
158
172
  saveProjects(projectPath, projects, options = {}) {
159
173
  const normalizedProjectPath = normalizeProjectPath(projectPath);
160
174
  const preferences = this.load(normalizedProjectPath);
161
- const reviveKeys = new Set(normalizeRemovedProjectPaths(options.reviveRemovedPaths, normalizedProjectPath));
162
- const removedProjectPaths = normalizeRemovedProjectPaths(preferences.removedProjectPaths, normalizedProjectPath)
175
+ const reviveKeys = new Set(normalizeRemovedProjectPaths(options.reviveRemovedPaths));
176
+ const removedProjectPaths = normalizeRemovedProjectPaths(preferences.removedProjectPaths)
163
177
  .filter((removedPath) => !reviveKeys.has(removedPath));
164
178
  const nextProjects = normalizeAiHubProjectList(projects, normalizedProjectPath, { removedProjectPaths });
165
179
  this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects, removedProjectPaths });
@@ -168,7 +182,7 @@ class AiHubPreferencesStore {
168
182
  mergeProjects(projectPath, projects) {
169
183
  const normalizedProjectPath = normalizeProjectPath(projectPath);
170
184
  const preferences = this.load(normalizedProjectPath);
171
- const removedProjectPaths = normalizeRemovedProjectPaths(preferences.removedProjectPaths, normalizedProjectPath);
185
+ const removedProjectPaths = normalizeRemovedProjectPaths(preferences.removedProjectPaths);
172
186
  const nextProjects = normalizeAiHubProjectList([...(preferences.projects || []), ...projects], normalizedProjectPath, { removedProjectPaths });
173
187
  this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects, removedProjectPaths });
174
188
  return nextProjects;
@@ -176,7 +190,7 @@ class AiHubPreferencesStore {
176
190
  removeProject(projectPath, projects, removedProjectPath) {
177
191
  const normalizedProjectPath = normalizeProjectPath(projectPath);
178
192
  const preferences = this.load(normalizedProjectPath);
179
- const removedProjectPaths = normalizeRemovedProjectPaths([...(preferences.removedProjectPaths || []), removedProjectPath], normalizedProjectPath);
193
+ const removedProjectPaths = normalizeRemovedProjectPaths([...(preferences.removedProjectPaths || []), removedProjectPath]);
180
194
  const nextProjects = normalizeAiHubProjectList(projects, normalizedProjectPath, { removedProjectPaths });
181
195
  this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects, removedProjectPaths });
182
196
  return nextProjects;
@@ -146,6 +146,13 @@ const FRAIM_INTERNAL_JOB_IDS = new Set([
146
146
  'setup-remote-hub',
147
147
  'update-registry-override',
148
148
  ]);
149
+ const DEFAULT_CONVERSATION_FLUSH_DELAY_MS = 2000;
150
+ function conversationFlushDelayMs() {
151
+ const configured = Number(process.env.FRAIM_HUB_CONVERSATION_FLUSH_MS);
152
+ if (Number.isFinite(configured) && configured >= 0)
153
+ return configured;
154
+ return DEFAULT_CONVERSATION_FLUSH_DELAY_MS;
155
+ }
149
156
  function listHubPersonaBundles() {
150
157
  return loadPersonaCapabilityModule()?.listPersonaCapabilityBundles() ?? [];
151
158
  }
@@ -1209,6 +1216,8 @@ class AiHubServer {
1209
1216
  this.app = (0, express_1.default)();
1210
1217
  this.runRegistry = new AiHubRunRegistry();
1211
1218
  this.cronHandles = new Map();
1219
+ this.pendingConversationWrites = new Map();
1220
+ this.conversationFlushTimer = null;
1212
1221
  this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
1213
1222
  this.projectPath = options.projectPath
1214
1223
  ? path_1.default.resolve(options.projectPath)
@@ -1398,6 +1407,7 @@ class AiHubServer {
1398
1407
  }
1399
1408
  }
1400
1409
  async stop() {
1410
+ this.flushPendingRunConversations();
1401
1411
  const closeServer = (srv) => new Promise((resolve, reject) => {
1402
1412
  const closable = srv;
1403
1413
  closable.closeIdleConnections?.();
@@ -1617,7 +1627,7 @@ class AiHubServer {
1617
1627
  },
1618
1628
  };
1619
1629
  }
1620
- persistRunConversation(run, activeId) {
1630
+ persistRunConversationNow(run, activeId) {
1621
1631
  try {
1622
1632
  // Issue #708: route the record to its scope bucket (manager/company runs get a
1623
1633
  // project-independent home); project runs continue to key by project path.
@@ -1629,6 +1639,30 @@ class AiHubServer {
1629
1639
  console.warn('[ai-hub] conversation store write failed:', error instanceof Error ? error.message : error);
1630
1640
  }
1631
1641
  }
1642
+ persistRunConversation(run, activeId) {
1643
+ this.pendingConversationWrites.delete(run.id);
1644
+ this.persistRunConversationNow(run, activeId);
1645
+ }
1646
+ scheduleRunConversationPersistence(run, activeId) {
1647
+ this.pendingConversationWrites.set(run.id, { run, activeId });
1648
+ if (this.conversationFlushTimer)
1649
+ return;
1650
+ this.conversationFlushTimer = setTimeout(() => {
1651
+ this.flushPendingRunConversations();
1652
+ }, conversationFlushDelayMs());
1653
+ this.conversationFlushTimer.unref?.();
1654
+ }
1655
+ flushPendingRunConversations() {
1656
+ if (this.conversationFlushTimer) {
1657
+ clearTimeout(this.conversationFlushTimer);
1658
+ this.conversationFlushTimer = null;
1659
+ }
1660
+ const pending = Array.from(this.pendingConversationWrites.values());
1661
+ this.pendingConversationWrites.clear();
1662
+ for (const entry of pending) {
1663
+ this.persistRunConversationNow(entry.run, entry.activeId);
1664
+ }
1665
+ }
1632
1666
  // Issue #512 (S3, R13) — derive the four Get-started step states, then let any
1633
1667
  // persisted `true` in ~/.fraim/{install-state,preferences}.json override the
1634
1668
  // derivation (so a completed step stays completed even if its signal vanishes).
@@ -1743,7 +1777,7 @@ class AiHubServer {
1743
1777
  });
1744
1778
  const updatedChild = this.runRegistry.get(childRun.id);
1745
1779
  if (updatedChild)
1746
- this.persistRunConversation(updatedChild);
1780
+ this.scheduleRunConversationPersistence(updatedChild);
1747
1781
  },
1748
1782
  onExit: (exitCode) => {
1749
1783
  this.runRegistry.update(childRun.id, (current) => {
@@ -1893,7 +1927,7 @@ class AiHubServer {
1893
1927
  });
1894
1928
  const updated = this.runRegistry.get(managerRun.id);
1895
1929
  if (updated)
1896
- this.persistRunConversation(updated, updated.conversationId || updated.id);
1930
+ this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
1897
1931
  },
1898
1932
  onExit: (exitCode) => {
1899
1933
  this.runRegistry.update(managerRun.id, (current) => {
@@ -2969,7 +3003,7 @@ class AiHubServer {
2969
3003
  const updated = this.runRegistry.get(run.id);
2970
3004
  if (updated) {
2971
3005
  this.maybeStartDelegatedChildRuns(updated);
2972
- this.persistRunConversation(updated, updated.conversationId || updated.id);
3006
+ this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
2973
3007
  }
2974
3008
  },
2975
3009
  onExit: (exitCode) => {
@@ -3164,7 +3198,7 @@ class AiHubServer {
3164
3198
  const updated = this.runRegistry.get(run.id);
3165
3199
  if (updated) {
3166
3200
  this.maybeStartDelegatedChildRuns(updated);
3167
- this.persistRunConversation(updated, updated.conversationId || updated.id);
3201
+ this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
3168
3202
  }
3169
3203
  },
3170
3204
  onExit: (exitCode) => {
@@ -3270,7 +3304,7 @@ class AiHubServer {
3270
3304
  });
3271
3305
  const updated = this.runRegistry.get(run.id);
3272
3306
  if (updated)
3273
- this.persistRunConversation(updated, updated.conversationId || updated.id);
3307
+ this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
3274
3308
  },
3275
3309
  onExit: (exitCode) => {
3276
3310
  this.runRegistry.update(run.id, (current) => {
@@ -3828,7 +3862,7 @@ class AiHubServer {
3828
3862
  });
3829
3863
  const updated = this.runRegistry.get(run.id);
3830
3864
  if (updated)
3831
- this.persistRunConversation(updated, updated.conversationId || updated.id);
3865
+ this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
3832
3866
  },
3833
3867
  onExit: (exitCode) => {
3834
3868
  this.runRegistry.update(run.id, (r) => {
@@ -56,17 +56,20 @@ function buildDeferredToolBootstrapSection(profile) {
56
56
  function buildFraimInvocationBody(profile = 'none') {
57
57
  return `Follow this process:
58
58
 
59
- ${buildDeferredToolBootstrapSection(profile)}1. **If the user did not specify a FRAIM job or topic**:
59
+ ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
60
+ Use this process only when the user explicitly invokes FRAIM, names a FRAIM job, asks what FRAIM job to run, or the active surface has already selected a FRAIM job. For ordinary requests, answer or work normally; do not scan FRAIM stubs first.
61
+
62
+ 2. **If the user did not specify a FRAIM job or topic after activation**:
60
63
  If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect \`fraim/personalized-employee/jobs/\` for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
61
64
 
62
- 2. **Find the match**:
63
- Match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no job matches, try a likely FRAIM skill with \`get_fraim_file({ path: "skills/<likely-category>/<argument>.md" })\` and confirm the match with the user.
65
+ 3. **Find the match**:
66
+ If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification. Do not pick the nearest catalog job.
64
67
 
65
- 3. **Load the full content**:
68
+ 4. **Load the full content**:
66
69
  - For jobs, call \`get_fraim_job({ job: "<matched-job-name>" })\`.
67
70
  - For skills, use the content returned by \`get_fraim_file(...)\`.
68
71
 
69
- 4. **Execute**:
72
+ 5. **Execute**:
70
73
  - For jobs, follow the phased instructions and use \`seekMentoring\` when the job requires phase transitions.
71
74
  - For skills, apply the skill steps directly to the user's current context.
72
75
  `;
@@ -96,18 +99,18 @@ ${buildFraimInvocationBody('generic-tool-discovery')}
96
99
  `;
97
100
  }
98
101
  function buildCodexSkillContent() {
99
- return `# FRAIM
100
-
102
+ return `# FRAIM
103
+
101
104
  ${buildFraimInvocationBody('codex-tool-search')}`;
102
105
  }
103
106
  function buildGrokSkillContent() {
104
- return `# FRAIM
105
-
107
+ return `# FRAIM
108
+
106
109
  ${buildFraimInvocationBody('generic-tool-discovery')}`;
107
110
  }
108
111
  function buildWindsurfCommandContent() {
109
- return `# FRAIM
110
-
112
+ return `# FRAIM
113
+
111
114
  ${buildFraimInvocationBody('generic-tool-discovery')}`;
112
115
  }
113
116
  function buildKiroCommandContent() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.208",
3
+ "version": "2.0.209",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -89,7 +89,7 @@
89
89
  "dotenv": "^16.4.7",
90
90
  "electron": "^41.2.2",
91
91
  "express": "^5.2.1",
92
- "fraim": "2.0.208",
92
+ "fraim": "2.0.209",
93
93
  "mongodb": "^7.0.0",
94
94
  "node-cron": "4.2.1",
95
95
  "node-edge-tts": "^1.2.10",