fraim 2.0.185 → 2.0.187

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.
@@ -3,10 +3,27 @@ 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.AiHubConversationStore = void 0;
6
+ exports.AiHubConversationStore = exports.COMPANY_SCOPE_KEY = exports.MANAGER_SCOPE_KEY = void 0;
7
+ exports.conversationScopeKey = conversationScopeKey;
7
8
  const fs_1 = __importDefault(require("fs"));
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
11
+ // Issue #708: reserved, project-independent bucket keys for non-project scopes.
12
+ // They are NOT filesystem paths and must never be passed through path.resolve.
13
+ exports.MANAGER_SCOPE_KEY = '@manager';
14
+ exports.COMPANY_SCOPE_KEY = '@company';
15
+ /**
16
+ * Issue #708: resolve the conversation store bucket key for a given scope.
17
+ * - 'manager'/'company' → a stable sentinel key (project-independent home).
18
+ * - 'project' (or undefined) → the resolved project path (existing behavior).
19
+ */
20
+ function conversationScopeKey(scope, projectPath) {
21
+ if (scope === 'manager')
22
+ return exports.MANAGER_SCOPE_KEY;
23
+ if (scope === 'company')
24
+ return exports.COMPANY_SCOPE_KEY;
25
+ return normalizeConversationKey(projectPath);
26
+ }
10
27
  const emptyProjectState = () => ({
11
28
  activeId: null,
12
29
  conversations: [],
@@ -23,8 +40,58 @@ function timestampValue(value) {
23
40
  }
24
41
  return 0;
25
42
  }
43
+ // Issue #708: sentinel scope keys (e.g. '@manager') are stable bucket keys, not paths —
44
+ // pass them through untouched; resolve everything else as a real project path.
45
+ function normalizeConversationKey(key) {
46
+ if (typeof key === 'string' && (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)) {
47
+ return key;
48
+ }
49
+ return path_1.default.resolve(key || process.cwd());
50
+ }
51
+ // Retained name for project-path stamping; now sentinel-aware so manager/company
52
+ // records keep their scope key rather than being resolved into cwd/@manager.
26
53
  function normalizeProjectPath(projectPath) {
27
- return path_1.default.resolve(projectPath || process.cwd());
54
+ return normalizeConversationKey(projectPath);
55
+ }
56
+ // Issue #708: one-time, idempotent migration. Legacy manager/company-invoked runs were
57
+ // stored inside project buckets and distinguished only by the client-side `invokedArea`
58
+ // field. Fold them into the reserved scope buckets (stamping `scope`) so they have a
59
+ // stable, project-independent home. Runs already in a scope bucket are left untouched.
60
+ function migrateScopeBuckets(store) {
61
+ const projects = {};
62
+ const ensure = (key) => {
63
+ if (!projects[key])
64
+ projects[key] = { activeId: null, conversations: [] };
65
+ return projects[key];
66
+ };
67
+ // Seed existing scope buckets first so their activeId is preserved.
68
+ for (const [key, state] of Object.entries(store.projects || {})) {
69
+ if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY) {
70
+ ensure(key).conversations.push(...(state.conversations || []));
71
+ projects[key].activeId = state.activeId ?? null;
72
+ }
73
+ }
74
+ for (const [key, state] of Object.entries(store.projects || {})) {
75
+ if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)
76
+ continue;
77
+ const stay = [];
78
+ for (const conv of (state.conversations || [])) {
79
+ const scope = conv.scope
80
+ ?? conv.invokedArea;
81
+ if (scope === 'manager' || scope === 'company') {
82
+ const bucketKey = scope === 'manager' ? exports.MANAGER_SCOPE_KEY : exports.COMPANY_SCOPE_KEY;
83
+ const bucket = ensure(bucketKey);
84
+ if (!bucket.conversations.some((c) => c && c.id === conv.id)) {
85
+ bucket.conversations.push({ ...conv, scope: scope });
86
+ }
87
+ }
88
+ else {
89
+ stay.push(conv);
90
+ }
91
+ }
92
+ projects[key] = { activeId: state.activeId ?? null, conversations: stay };
93
+ }
94
+ return { version: 1, projects };
28
95
  }
29
96
  function normalizeConversation(projectPath, raw) {
30
97
  if (!raw || typeof raw !== 'object')
@@ -77,6 +144,32 @@ class AiHubConversationStore {
77
144
  const normalizedProjectPath = normalizeProjectPath(projectPath);
78
145
  return normalizeProjectState(normalizedProjectPath, state.projects[normalizedProjectPath]);
79
146
  }
147
+ // Issue #719: delete a project's KEY from the store entirely. Writing an empty
148
+ // conversation list is not enough — knownProjects re-derives a project from
149
+ // every stored key (listProjectPaths), so removal must drop the key itself.
150
+ // Sentinel scope buckets (@manager/@company) are not projects and never removable.
151
+ removeProject(projectPath) {
152
+ if (projectPath === exports.MANAGER_SCOPE_KEY || projectPath === exports.COMPANY_SCOPE_KEY)
153
+ return false;
154
+ const canonicalKey = (value) => {
155
+ const resolved = path_1.default.resolve(value);
156
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
157
+ };
158
+ const target = canonicalKey(projectPath);
159
+ const state = this.readStore();
160
+ let removed = false;
161
+ for (const key of Object.keys(state.projects)) {
162
+ if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)
163
+ continue;
164
+ if (canonicalKey(key) === target) {
165
+ delete state.projects[key];
166
+ removed = true;
167
+ }
168
+ }
169
+ if (removed)
170
+ this.writeStore(state);
171
+ return removed;
172
+ }
80
173
  replaceProject(projectPath, next) {
81
174
  const normalizedProjectPath = normalizeProjectPath(projectPath);
82
175
  const normalized = normalizeProjectState(normalizedProjectPath, next);
@@ -149,10 +242,8 @@ class AiHubConversationStore {
149
242
  if (raw.version !== 1 || !raw.projects || typeof raw.projects !== 'object') {
150
243
  return { version: 1, projects: {} };
151
244
  }
152
- return {
153
- version: 1,
154
- projects: raw.projects,
155
- };
245
+ // Issue #708: fold legacy manager/company-invoked runs into scope buckets on read.
246
+ return migrateScopeBuckets({ version: 1, projects: raw.projects });
156
247
  }
157
248
  catch {
158
249
  return { version: 1, projects: {} };
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createHubEvent = exports.createHubMessage = exports.ScriptedHostRuntime = exports.FakeHostRuntime = exports.CliHostRuntime = void 0;
7
7
  exports.parseSeekMentoringSignal = parseSeekMentoringSignal;
8
+ exports.parseFraimJobLoadSignal = parseFraimJobLoadSignal;
8
9
  exports.parseUsageSignal = parseUsageSignal;
9
10
  exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
10
11
  exports.detectEmployees = detectEmployees;
@@ -91,6 +92,58 @@ function parseSeekMentoringSignal(line) {
91
92
  }
92
93
  return null;
93
94
  }
95
+ // Issue #710: extract the job name from a get_fraim_job tool call. This
96
+ // is structured known-job evidence; the server still validates the job id
97
+ // against the catalog before promoting any run.
98
+ function parseFraimJobLoadSignal(line) {
99
+ if (!line.includes('get_fraim_job'))
100
+ return null;
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(line);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ if (typeof parsed !== 'object' || parsed === null)
109
+ return null;
110
+ const obj = parsed;
111
+ // Codex shape: { type: 'item.started' | 'item.completed', item:
112
+ // { type: 'mcp_tool_call', tool: 'get_fraim_job', arguments: { job } } }.
113
+ if ((obj.type === 'item.started' || obj.type === 'item.completed') &&
114
+ typeof obj.item === 'object' && obj.item !== null) {
115
+ const item = obj.item;
116
+ const tool = typeof item.tool === 'string' ? item.tool : '';
117
+ if (item.type === 'mcp_tool_call' && isGetFraimJobTool(tool)) {
118
+ const sig = readFraimJobFromArgs(item.arguments);
119
+ if (sig)
120
+ return sig;
121
+ }
122
+ }
123
+ // Claude Code shape: tool_use blocks live inside message.content with
124
+ // an MCP-prefixed name such as mcp__fraim__get_fraim_job.
125
+ const candidates = [obj];
126
+ if (Array.isArray(obj.content))
127
+ candidates.push(...obj.content);
128
+ if (typeof obj.message === 'object' && obj.message !== null) {
129
+ const msg = obj.message;
130
+ if (Array.isArray(msg.content))
131
+ candidates.push(...msg.content);
132
+ }
133
+ for (const candidate of candidates) {
134
+ if (typeof candidate !== 'object' || candidate === null)
135
+ continue;
136
+ const c = candidate;
137
+ const isToolUse = c.type === 'tool_use' || c.type === 'function_call';
138
+ const nameField = typeof c.name === 'string' ? c.name : (typeof c.tool_name === 'string' ? c.tool_name : '');
139
+ if (!isToolUse || !isGetFraimJobTool(nameField))
140
+ continue;
141
+ const sig = readFraimJobFromArgs(c.input || c.arguments || c.parameters);
142
+ if (sig)
143
+ return sig;
144
+ }
145
+ return null;
146
+ }
94
147
  // Issue #347 — extract per-turn usage from the host's JSON stream.
95
148
  // Codex: `{"type":"turn.completed","usage":{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens}}`.
96
149
  // Claude Code: `{"type":"result", ..., "usage":{input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens}, "total_cost_usd": ...}`.
@@ -256,6 +309,41 @@ function readAgentFromArgs(args) {
256
309
  return null;
257
310
  return { agentName, agentModel };
258
311
  }
312
+ function isGetFraimJobTool(toolName) {
313
+ return toolName === 'get_fraim_job' ||
314
+ toolName === 'mcp__fraim__get_fraim_job' ||
315
+ toolName.endsWith('get_fraim_job');
316
+ }
317
+ function readFraimJobFromArgs(rawArgs) {
318
+ const args = normalizeToolArgs(rawArgs);
319
+ if (!args)
320
+ return null;
321
+ const rawJob = stringValue(args.job) ||
322
+ stringValue(args.jobName) ||
323
+ stringValue(args.job_id) ||
324
+ stringValue(args.name);
325
+ const jobId = rawJob.trim().toLowerCase();
326
+ if (!jobId)
327
+ return null;
328
+ return { jobId, source: 'get_fraim_job' };
329
+ }
330
+ function normalizeToolArgs(rawArgs) {
331
+ if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
332
+ return rawArgs;
333
+ }
334
+ if (typeof rawArgs !== 'string' || rawArgs.trim().length === 0)
335
+ return null;
336
+ try {
337
+ const parsed = JSON.parse(rawArgs);
338
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
339
+ return parsed;
340
+ }
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ return null;
346
+ }
259
347
  function numberOrNull(v) {
260
348
  return typeof v === 'number' && Number.isFinite(v) ? v : null;
261
349
  }
@@ -987,17 +1075,24 @@ function parseHostLine(hostId, line) {
987
1075
  const trimmed = line.trim();
988
1076
  if (!trimmed)
989
1077
  return {};
990
- // Issue #347: scan every line for the three signals the Hub UI cares
991
- // about seekMentoring (drives the tracker), turn-level usage
992
- // (drives the totals tokens/cost), and the agent identity from
993
- // fraim_connect (used to look up cost when the host doesn't emit it).
1078
+ // Scan every line for structured signals the Hub UI cares about:
1079
+ // seekMentoring (tracker), get_fraim_job (job identity promotion),
1080
+ // turn-level usage (totals), and fraim_connect agent identity
1081
+ // (cost lookup when the host doesn't emit it).
994
1082
  const seekMentoring = parseSeekMentoringSignal(trimmed);
1083
+ const fraimJob = parseFraimJobLoadSignal(trimmed);
995
1084
  const usage = parseUsageSignal(trimmed);
996
1085
  const agentIdentity = parseAgentIdentitySignal(trimmed);
997
1086
  const withSignal = (event) => {
998
- if (!seekMentoring && !usage && !agentIdentity)
1087
+ if (!seekMentoring && !fraimJob && !usage && !agentIdentity)
999
1088
  return event;
1000
- return { ...event, ...(seekMentoring ? { seekMentoring } : {}), ...(usage ? { usage } : {}), ...(agentIdentity ? { agentIdentity } : {}) };
1089
+ return {
1090
+ ...event,
1091
+ ...(seekMentoring ? { seekMentoring } : {}),
1092
+ ...(fraimJob ? { fraimJob } : {}),
1093
+ ...(usage ? { usage } : {}),
1094
+ ...(agentIdentity ? { agentIdentity } : {}),
1095
+ };
1001
1096
  };
1002
1097
  if (hostId === 'codex') {
1003
1098
  try {
@@ -1339,9 +1434,10 @@ class ScriptedHostRuntime {
1339
1434
  { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1340
1435
  { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1341
1436
  ];
1342
- // Track each active run so the test can emit signals at it. Key is the
1343
- // sessionId we hand back on startRun; mapping sessionId handlers
1344
- // lets emitPhase() reach the right run's onEvent callback.
1437
+ // Track each active run so the test can emit signals at it. The Hub
1438
+ // passes the run id as the requested start session id in test/demo paths;
1439
+ // real resume flows still use the host session id. Resolving by the
1440
+ // supplied id first lets concurrent scripted runs receive targeted events.
1345
1441
  this.handlersBySession = new Map();
1346
1442
  // Tracks runDiscriminant per sessionId so consecutive emitPhase calls
1347
1443
  // can resolve onSuccess routing without each test having to repeat it.
@@ -1352,8 +1448,8 @@ class ScriptedHostRuntime {
1352
1448
  }
1353
1449
  startRun(_hostId, _projectPath, _message, handlers, requestedSessionId) {
1354
1450
  const sessionId = requestedSessionId || (0, crypto_1.randomUUID)();
1355
- handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
1356
1451
  this.handlersBySession.set(sessionId, handlers);
1452
+ handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
1357
1453
  return this.spawnDouble();
1358
1454
  }
1359
1455
  continueRun(_hostId, _projectPath, sessionId, _message, handlers) {
@@ -1363,6 +1459,7 @@ class ScriptedHostRuntime {
1363
1459
  }
1364
1460
  startDirectRun(_hostId, _message, _projectPath, handlers, requestedSessionId) {
1365
1461
  const sessionId = requestedSessionId || (0, crypto_1.randomUUID)();
1462
+ this.handlersBySession.set(sessionId, handlers);
1366
1463
  handlers.onEvent({ sessionId, raw: 'scripted-direct-session-start' }, 'system');
1367
1464
  return this.spawnDouble();
1368
1465
  }
@@ -1420,6 +1517,18 @@ class ScriptedHostRuntime {
1420
1517
  },
1421
1518
  }, 'stdout');
1422
1519
  }
1520
+ // Test API — emit the structured job-load signal that get_fraim_job
1521
+ // produces before the first seekMentoring phase call.
1522
+ emitFraimJobLoad(runId, jobId) {
1523
+ const target = this.resolveSession(runId);
1524
+ if (!target)
1525
+ return;
1526
+ target.handlers.onEvent({
1527
+ sessionId: target.sessionId,
1528
+ raw: `scripted-get_fraim_job:${jobId}`,
1529
+ fraimJob: { jobId, source: 'get_fraim_job' },
1530
+ }, 'stdout');
1531
+ }
1423
1532
  // Test API — emit a per-turn usage signal in the normalized shape
1424
1533
  // the server expects. Mirrors what parseUsageSignal would produce
1425
1534
  // from a real host's stream.
@@ -1464,13 +1573,16 @@ class ScriptedHostRuntime {
1464
1573
  this.handlersBySession.clear();
1465
1574
  this.discriminantBySession.clear();
1466
1575
  }
1467
- // The Hub server keeps its own sessionId map keyed off run.sessionId
1468
- // but at the time the test calls emitPhase(runId), we may only have
1469
- // one handler registered (the latest start). Resolve by sessionId
1470
- // first, fall back to the most recent registered handler.
1471
- resolveSession(_runId) {
1576
+ // The Hub passes run.id as the requested session id for scripted starts,
1577
+ // so tests/demo controls can target a specific run even when multiple runs
1578
+ // are active. Fall back to the latest handler for older callers that do not
1579
+ // provide a known id.
1580
+ resolveSession(runId) {
1472
1581
  if (this.handlersBySession.size === 0)
1473
1582
  return null;
1583
+ const direct = this.handlersBySession.get(runId);
1584
+ if (direct)
1585
+ return { sessionId: runId, handlers: direct };
1474
1586
  const entries = [...this.handlersBySession.entries()];
1475
1587
  const [sessionId, handlers] = entries[entries.length - 1];
1476
1588
  return { sessionId, handlers };
@@ -134,6 +134,7 @@ class OpenClawAiHubBridge {
134
134
  jobId: run.jobId,
135
135
  projectPath,
136
136
  lastSeenAt: new Date().toISOString(),
137
+ managerTexts: [inbound.text],
137
138
  };
138
139
  this.threads.set(key, state);
139
140
  this.persistThreadState();
@@ -147,6 +148,7 @@ class OpenClawAiHubBridge {
147
148
  }
148
149
  run = continued.data;
149
150
  state.lastSeenAt = new Date().toISOString();
151
+ state.managerTexts = [...(state.managerTexts ?? []), inbound.text];
150
152
  this.persistThreadState();
151
153
  }
152
154
  const settled = await this.waitForSettlement(run.id);
@@ -193,6 +195,19 @@ class OpenClawAiHubBridge {
193
195
  return this.fetchRun(runId);
194
196
  }
195
197
  presentThread(state, run) {
198
+ // The Hub records the invocation it sent as each manager bubble (#696). For a channel
199
+ // transcript we substitute the user's own words (state.managerTexts, in turn order)
200
+ // for manager-role messages, so the thread reflects what the user actually typed.
201
+ const managerTexts = state.managerTexts ?? [];
202
+ let managerIdx = 0;
203
+ const transcript = run.messages.map((message) => ({
204
+ role: message.role,
205
+ text: message.role === 'manager' && managerIdx < managerTexts.length
206
+ ? managerTexts[managerIdx++]
207
+ : message.text,
208
+ createdAt: message.createdAt,
209
+ }));
210
+ const latestManagerMessage = [...transcript].reverse().find((m) => m.role === 'manager')?.text || null;
196
211
  return {
197
212
  channelId: state.channelId,
198
213
  threadId: state.threadId,
@@ -204,13 +219,9 @@ class OpenClawAiHubBridge {
204
219
  currentPhase: run.currentPhase || null,
205
220
  stages: run.stages || [],
206
221
  totals: run.totals || null,
207
- latestManagerMessage: lastMessage(run.messages, 'manager')?.text || null,
222
+ latestManagerMessage,
208
223
  latestEmployeeMessage: lastMessage(run.messages, 'employee')?.text || null,
209
- transcript: run.messages.map((message) => ({
210
- role: message.role,
211
- text: message.text,
212
- createdAt: message.createdAt,
213
- })),
224
+ transcript,
214
225
  };
215
226
  }
216
227
  threadKey(channelId, threadId) {