fraim-hub 2.0.340 → 2.0.341
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/registry/scripts/learning/completion-state.js +10 -2
- package/dist/src/ai-hub/desktop-main.js +7 -3
- package/dist/src/ai-hub/hosts.js +28 -0
- package/dist/src/ai-hub/manager-turns.js +16 -0
- package/dist/src/ai-hub/server.js +148 -6
- package/dist/src/core/handoff-contracts.js +122 -21
- package/dist/src/core/proposal-set-contract.js +214 -0
- package/dist/src/core/run-recovery-state.js +9 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +128 -1
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +59 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -1
- package/dist/src/local-mcp-server/learning-usage-store.js +163 -38
- package/package.json +5 -2
- package/public/ai-hub/coach-intake-surface.js +415 -0
- package/public/ai-hub/index.html +21 -63
- package/public/ai-hub/pane-surfaces.js +315 -0
- package/public/ai-hub/review.css +42 -0
- package/public/ai-hub/script.js +473 -370
- package/public/ai-hub/styles.css +61 -40
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* FRAIM Hub - main-pane surface host (issue #1911 R16).
|
|
3
|
+
*
|
|
4
|
+
* The Hub's main pane has grown a series of hand-wired, mutually exclusive
|
|
5
|
+
* siblings inside <main class="conversation">: the idle empty state, the hire
|
|
6
|
+
* strip, the active conversation, the A/B compare panel. Each one was added by
|
|
7
|
+
* hand and each one is toggled by hand. This module turns that into one slot
|
|
8
|
+
* with a registration API, so a future surface - a form, a report, a picker, a
|
|
9
|
+
* chart, any markup at all - mounts into the pane without editing the pane.
|
|
10
|
+
*
|
|
11
|
+
* It is deliberately feature-agnostic. Every application-specific selector is
|
|
12
|
+
* injected through configure(), and every piece of content comes from the
|
|
13
|
+
* surface's own render(). A test can drive this host with a surface that has
|
|
14
|
+
* nothing to do with any particular Hub feature.
|
|
15
|
+
*
|
|
16
|
+
* Surface definition:
|
|
17
|
+
* {
|
|
18
|
+
* id: 'my-surface', // required, unique
|
|
19
|
+
* render: (ctx) => Node | DocumentFragment | string, // required
|
|
20
|
+
* focusSelector: '#my-first-control', // optional, else first focusable
|
|
21
|
+
* onOpen: (ctx) => void, // optional
|
|
22
|
+
* onClose: (ctx) => void, // optional
|
|
23
|
+
* onEscape:(ctx) => boolean, // optional; true = handled, stay open
|
|
24
|
+
* composer: { // optional; claims the composer
|
|
25
|
+
* chipLabel: (ctx) => string|null,
|
|
26
|
+
* chipClearLabel: (ctx) => string,
|
|
27
|
+
* placeholder: (ctx) => string,
|
|
28
|
+
* validate: ({ text, context }) => { ok, message?, focus? },
|
|
29
|
+
* submit: ({ text, context }) => { ok, error?, keepOpen? } | Promise<...>,
|
|
30
|
+
* },
|
|
31
|
+
* }
|
|
32
|
+
*/
|
|
33
|
+
(function (global) {
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
var surfaces = new Map();
|
|
37
|
+
var config = null;
|
|
38
|
+
var active = null; // { id, def, context, trigger }
|
|
39
|
+
var submitting = false;
|
|
40
|
+
|
|
41
|
+
function query(selector) {
|
|
42
|
+
if (!selector) return null;
|
|
43
|
+
try {
|
|
44
|
+
return document.querySelector(selector);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hostElement() { return query(config && config.hostSelector); }
|
|
51
|
+
function composerTextElement() { return query(config && config.composerTextSelector); }
|
|
52
|
+
function chipElement() { return query(config && config.chipSelector); }
|
|
53
|
+
function chipLabelElement() { return query(config && config.chipLabelSelector); }
|
|
54
|
+
function chipClearElement() { return query(config && config.chipClearSelector); }
|
|
55
|
+
function composerErrorElement() { return query(config && config.composerErrorSelector); }
|
|
56
|
+
|
|
57
|
+
function notifyChange() {
|
|
58
|
+
if (config && typeof config.onChange === 'function') config.onChange();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Accepts whatever render() returned and puts it in the host, replacing any
|
|
62
|
+
// previous content. A string is treated as markup so a surface can be a
|
|
63
|
+
// template literal and nothing more.
|
|
64
|
+
function mount(host, content) {
|
|
65
|
+
host.innerHTML = '';
|
|
66
|
+
if (content == null) return;
|
|
67
|
+
if (typeof content === 'string') {
|
|
68
|
+
host.innerHTML = content;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (typeof content.nodeType === 'number') {
|
|
72
|
+
host.appendChild(content);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (typeof content.length === 'number') {
|
|
76
|
+
for (var i = 0; i < content.length; i += 1) host.appendChild(content[i]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
var FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
81
|
+
|
|
82
|
+
function focusInto(host, def) {
|
|
83
|
+
var target = def.focusSelector ? host.querySelector(def.focusSelector) : null;
|
|
84
|
+
if (!target) {
|
|
85
|
+
var candidates = host.querySelectorAll(FOCUSABLE);
|
|
86
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
87
|
+
var el = candidates[i];
|
|
88
|
+
if (!el.disabled && !el.hidden && el.getClientRects().length) { target = el; break; }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (target && typeof target.focus === 'function') target.focus();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function composerOf(def) {
|
|
95
|
+
return def && def.composer ? def.composer : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function callContract(fn, fallback) {
|
|
99
|
+
if (typeof fn !== 'function') return fallback;
|
|
100
|
+
try {
|
|
101
|
+
return fn(active ? active.context : null);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (global.console && console.warn) console.warn('[pane-surfaces] contract call failed:', err);
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function setComposerError(message) {
|
|
109
|
+
var el = composerErrorElement();
|
|
110
|
+
if (el) el.textContent = message || '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Paints the chip and the placeholder the active surface asked for. Called
|
|
114
|
+
// on open, on refresh, and by the host application whenever it re-syncs the
|
|
115
|
+
// composer, so the two never disagree about who owns the chip.
|
|
116
|
+
function renderComposerChrome() {
|
|
117
|
+
var chip = chipElement();
|
|
118
|
+
var label = chipLabelElement();
|
|
119
|
+
var clear = chipClearElement();
|
|
120
|
+
var text = composerTextElement();
|
|
121
|
+
if (!active || !composerOf(active.def)) return false;
|
|
122
|
+
var chipLabel = callContract(composerOf(active.def).chipLabel, null);
|
|
123
|
+
if (chip && label && chipLabel) {
|
|
124
|
+
label.textContent = chipLabel;
|
|
125
|
+
chip.hidden = false;
|
|
126
|
+
if (clear) clear.setAttribute('aria-label', callContract(composerOf(active.def).chipClearLabel, 'Cancel'));
|
|
127
|
+
}
|
|
128
|
+
if (text) {
|
|
129
|
+
var placeholder = callContract(composerOf(active.def).placeholder, null);
|
|
130
|
+
if (placeholder) text.placeholder = placeholder;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
var api = {
|
|
136
|
+
/**
|
|
137
|
+
* Binds the host to its application. Every selector here is the
|
|
138
|
+
* application's, not this module's, which is what keeps this file free of
|
|
139
|
+
* feature knowledge.
|
|
140
|
+
*/
|
|
141
|
+
configure: function (options) {
|
|
142
|
+
config = options || {};
|
|
143
|
+
return api;
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
register: function (def) {
|
|
147
|
+
if (!def || !def.id) throw new Error('pane-surfaces: a surface needs an id');
|
|
148
|
+
if (typeof def.render !== 'function') throw new Error('pane-surfaces: ' + def.id + ' needs a render()');
|
|
149
|
+
surfaces.set(def.id, def);
|
|
150
|
+
return api;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
isRegistered: function (id) { return surfaces.has(id); },
|
|
154
|
+
|
|
155
|
+
activeId: function () { return active ? active.id : null; },
|
|
156
|
+
|
|
157
|
+
activeContext: function () { return active ? active.context : null; },
|
|
158
|
+
|
|
159
|
+
isOpen: function (id) { return !!active && (id === undefined || active.id === id); },
|
|
160
|
+
|
|
161
|
+
isSubmitting: function () { return submitting; },
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Mounts a registered surface into the pane. Opening while another surface
|
|
165
|
+
* is open replaces it (closing the first properly) rather than stacking.
|
|
166
|
+
* Re-opening the surface that is already active re-renders it in place, so
|
|
167
|
+
* a double click on an entry point cannot duplicate anything.
|
|
168
|
+
*/
|
|
169
|
+
open: function (id, context) {
|
|
170
|
+
var def = surfaces.get(id);
|
|
171
|
+
if (!def) throw new Error('pane-surfaces: no surface registered as "' + id + '"');
|
|
172
|
+
var host = hostElement();
|
|
173
|
+
if (!host) return null;
|
|
174
|
+
var trigger = document.activeElement;
|
|
175
|
+
if (active && active.id !== id) closeActive({ restoreFocus: false });
|
|
176
|
+
var reopening = !!active && active.id === id;
|
|
177
|
+
active = {
|
|
178
|
+
id: id,
|
|
179
|
+
def: def,
|
|
180
|
+
context: context === undefined ? (reopening ? active.context : null) : context,
|
|
181
|
+
trigger: reopening ? active.trigger : trigger,
|
|
182
|
+
};
|
|
183
|
+
setComposerError('');
|
|
184
|
+
// onOpen before render: a surface prepares its state there, and render()
|
|
185
|
+
// must draw the prepared state, not the state a previous open left behind.
|
|
186
|
+
if (!reopening && typeof def.onOpen === 'function') def.onOpen(active.context);
|
|
187
|
+
mount(host, def.render(active.context));
|
|
188
|
+
host.hidden = false;
|
|
189
|
+
host.dataset.surfaceId = id;
|
|
190
|
+
renderComposerChrome();
|
|
191
|
+
notifyChange();
|
|
192
|
+
focusInto(host, def);
|
|
193
|
+
return active.context;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/** Re-runs render() for the active surface without re-firing onOpen. */
|
|
197
|
+
refresh: function () {
|
|
198
|
+
if (!active) return false;
|
|
199
|
+
var host = hostElement();
|
|
200
|
+
if (!host) return false;
|
|
201
|
+
var focusedId = document.activeElement && document.activeElement.id;
|
|
202
|
+
var selectionStart = null;
|
|
203
|
+
var selectionEnd = null;
|
|
204
|
+
var focused = document.activeElement;
|
|
205
|
+
if (focused && typeof focused.selectionStart === 'number') {
|
|
206
|
+
selectionStart = focused.selectionStart;
|
|
207
|
+
selectionEnd = focused.selectionEnd;
|
|
208
|
+
}
|
|
209
|
+
mount(host, active.def.render(active.context));
|
|
210
|
+
renderComposerChrome();
|
|
211
|
+
if (focusedId) {
|
|
212
|
+
var again = host.querySelector('#' + (global.CSS && CSS.escape ? CSS.escape(focusedId) : focusedId));
|
|
213
|
+
if (again && typeof again.focus === 'function') {
|
|
214
|
+
again.focus();
|
|
215
|
+
if (selectionStart !== null && typeof again.setSelectionRange === 'function') {
|
|
216
|
+
try { again.setSelectionRange(selectionStart, selectionEnd); } catch (err) { /* not a text field */ }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
close: function (options) { return closeActive(options); },
|
|
224
|
+
|
|
225
|
+
closeIfActive: function (id, options) {
|
|
226
|
+
if (!active || active.id !== id) return false;
|
|
227
|
+
return closeActive(options);
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/** The composer contract of the active surface, or null when none claims it. */
|
|
231
|
+
composerContract: function () {
|
|
232
|
+
if (!active) return null;
|
|
233
|
+
return composerOf(active.def);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/** Paints chip + placeholder for the active surface. Returns false when no surface owns the composer. */
|
|
237
|
+
renderComposerChrome: renderComposerChrome,
|
|
238
|
+
|
|
239
|
+
setComposerError: setComposerError,
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Routed from the application's Escape handling so there is exactly one
|
|
243
|
+
* keydown listener in the app. Returns true when this module consumed the key.
|
|
244
|
+
*/
|
|
245
|
+
handleEscape: function () {
|
|
246
|
+
if (!active) return false;
|
|
247
|
+
if (typeof active.def.onEscape === 'function' && active.def.onEscape(active.context) === true) return true;
|
|
248
|
+
closeActive();
|
|
249
|
+
return true;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Routed from the application's send action. Returns false when no surface
|
|
254
|
+
* claims the composer, so the caller falls through to its own behavior.
|
|
255
|
+
*/
|
|
256
|
+
submitFromComposer: async function (text) {
|
|
257
|
+
var contract = api.composerContract();
|
|
258
|
+
if (!contract || typeof contract.submit !== 'function') return false;
|
|
259
|
+
if (submitting) return true;
|
|
260
|
+
var input = { text: text == null ? '' : String(text), context: active.context };
|
|
261
|
+
setComposerError('');
|
|
262
|
+
if (typeof contract.validate === 'function') {
|
|
263
|
+
var verdict = contract.validate(input) || { ok: true };
|
|
264
|
+
if (!verdict.ok) {
|
|
265
|
+
setComposerError(verdict.message || '');
|
|
266
|
+
var focusTarget = typeof verdict.focus === 'string' ? query(verdict.focus) : verdict.focus;
|
|
267
|
+
if (focusTarget && typeof focusTarget.focus === 'function') focusTarget.focus();
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
submitting = true;
|
|
272
|
+
var result;
|
|
273
|
+
try {
|
|
274
|
+
result = await contract.submit(input);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
result = { ok: false, error: (err && err.message) || 'Could not complete this action.' };
|
|
277
|
+
} finally {
|
|
278
|
+
submitting = false;
|
|
279
|
+
}
|
|
280
|
+
result = result || { ok: true };
|
|
281
|
+
if (result.ok === false) {
|
|
282
|
+
setComposerError(result.error || 'Could not complete this action.');
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
var composerText = composerTextElement();
|
|
286
|
+
if (composerText) composerText.value = '';
|
|
287
|
+
if (!result.keepOpen) closeActive({ restoreFocus: false });
|
|
288
|
+
else notifyChange();
|
|
289
|
+
return true;
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
function closeActive(options) {
|
|
294
|
+
if (!active) return false;
|
|
295
|
+
var opts = options || {};
|
|
296
|
+
var closing = active;
|
|
297
|
+
active = null;
|
|
298
|
+
var host = hostElement();
|
|
299
|
+
if (host) {
|
|
300
|
+
host.hidden = true;
|
|
301
|
+
host.innerHTML = '';
|
|
302
|
+
delete host.dataset.surfaceId;
|
|
303
|
+
}
|
|
304
|
+
setComposerError('');
|
|
305
|
+
if (typeof closing.def.onClose === 'function') closing.def.onClose(closing.context);
|
|
306
|
+
notifyChange();
|
|
307
|
+
if (opts.restoreFocus !== false && closing.trigger && closing.trigger.isConnected
|
|
308
|
+
&& typeof closing.trigger.focus === 'function') {
|
|
309
|
+
closing.trigger.focus();
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
global.PaneSurfaces = api;
|
|
315
|
+
}(typeof window !== 'undefined' ? window : globalThis));
|
package/public/ai-hub/review.css
CHANGED
|
@@ -232,6 +232,38 @@
|
|
|
232
232
|
border-left: 3px solid var(--line);
|
|
233
233
|
padding-left: 10px;
|
|
234
234
|
}
|
|
235
|
+
/* Issue #1857: a reopened claim. Built from the same tokens as .lp-example
|
|
236
|
+
directly above, because it is the same kind of thing, a quoted aside that
|
|
237
|
+
qualifies the lesson, and it should not read as a new visual language. The
|
|
238
|
+
accent separates it from the example rather than competing with it. */
|
|
239
|
+
.learn-prop .lp-reopen {
|
|
240
|
+
font-size: 13px;
|
|
241
|
+
line-height: 1.45;
|
|
242
|
+
color: var(--text);
|
|
243
|
+
border-left: 3px solid var(--warn, #b8860b);
|
|
244
|
+
background: color-mix(in srgb, var(--warn, #b8860b) 8%, transparent);
|
|
245
|
+
padding: 6px 10px;
|
|
246
|
+
border-radius: 3px;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* Issue #1857 R7: claims suppressed because the manager already decided them.
|
|
250
|
+
Deliberately quieter than a card: this is a receipt, not a request. It sits
|
|
251
|
+
below the batch so the eye reaches the decisions first. */
|
|
252
|
+
.learn-suppressed {
|
|
253
|
+
margin-top: 10px;
|
|
254
|
+
padding-top: 8px;
|
|
255
|
+
border-top: 1px solid var(--line);
|
|
256
|
+
font-size: 12px;
|
|
257
|
+
line-height: 1.6;
|
|
258
|
+
color: var(--muted);
|
|
259
|
+
}
|
|
260
|
+
.learn-suppressed .ls-lead {
|
|
261
|
+
font-weight: 600;
|
|
262
|
+
}
|
|
263
|
+
.learn-suppressed .ls-row {
|
|
264
|
+
padding-left: 2px;
|
|
265
|
+
}
|
|
266
|
+
|
|
235
267
|
.learn-prop .lp-actions {
|
|
236
268
|
display: flex;
|
|
237
269
|
align-items: center;
|
|
@@ -887,6 +919,16 @@
|
|
|
887
919
|
background: var(--bg);
|
|
888
920
|
}
|
|
889
921
|
|
|
922
|
+
/* Issue #1857 R19: the rejected section reuses the Archived section's shape
|
|
923
|
+
entirely, so a manager reads one collapsed list the same way in both places.
|
|
924
|
+
Only the card is dimmed, because a refusal is settled rather than pending. */
|
|
925
|
+
.learn-rejected-section > .learn-prop {
|
|
926
|
+
opacity: 0.78;
|
|
927
|
+
}
|
|
928
|
+
.learn-rejected-section .lp-title {
|
|
929
|
+
text-decoration: none;
|
|
930
|
+
}
|
|
931
|
+
|
|
890
932
|
|
|
891
933
|
/* Issue #1755: entries are authored as short labelled lines (Takeaway / Why this
|
|
892
934
|
matters / Example / What to do next). Collapsing them into one paragraph is
|