@chatpanel/events 0.96.0 → 0.97.0

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/citations.js CHANGED
@@ -77,3 +77,52 @@ export function linkifyCitations(answer, sources, { heading = 'Sources' } = {})
77
77
  .join('\n');
78
78
  return `${linked.trimEnd()}\n\n**${heading}**\n${list}\n`;
79
79
  }
80
+
81
+ // ---------------------------------------------------------------------------------------
82
+ // The collector — what a turn was GIVEN, gathered as its tools return it, and put back
83
+ // under the answer as links. Was the extension's `citationCollector`; the desktop had none,
84
+ // so its answers cited `[1]` with nothing to click.
85
+ // ---------------------------------------------------------------------------------------
86
+
87
+ /** Tools whose job is to RETURN material rather than to act on something. */
88
+ export const RETRIEVAL_TOOLS = Object.freeze(new Set(['find', 'source', 'history_search', 'web_search', 'search', 'fetch', 'read_page']));
89
+
90
+ const retrievalName = (name) => String(name || '').replace(/^mcp[_-]/, '').split('__')[0];
91
+
92
+ /**
93
+ * Wrap a toolset so every result is read for numbered sources.
94
+ *
95
+ * @param tools `{ execute, … }`; returned as `.tools`, wrapped
96
+ * @param onRetrieved `({ tool, count, chars, sources }) => void` — RETRIEVAL IS INPUT. Called
97
+ * per call that returned material, so the turn shows what it was given and
98
+ * by which tool. A retrieval tool that returned no LINKS still returned
99
+ * material (notes, past chats), and is reported with `count: 0` — silence
100
+ * there would under-report exactly the private sources this makes visible.
101
+ * @returns `{ tools, list(), apply(answer) }`
102
+ */
103
+ export function createCitationCollector(tools, { onRetrieved = null } = {}) {
104
+ const sources = new Map();
105
+ const collect = (name, out) => {
106
+ const body = typeof out === 'string' ? out : (out?.text || '');
107
+ if (!body) return;
108
+ const found = sourcesFromToolText(body);
109
+ for (const s of found) if (!sources.has(s.rank)) sources.set(s.rank, s);
110
+ if (!onRetrieved) return;
111
+ try {
112
+ if (found.length) onRetrieved({ tool: name, count: found.length, chars: body.length, sources: found.slice(0, 20).map((x) => ({ rank: x.rank, title: x.title || '', url: x.url || '' })) });
113
+ else if (RETRIEVAL_TOOLS.has(retrievalName(name))) onRetrieved({ tool: name, count: 0, chars: body.length, sources: [] });
114
+ } catch { /* reporting never breaks a turn */ }
115
+ };
116
+ const wrapped = tools && typeof tools.execute === 'function'
117
+ ? { ...tools, execute: async (name, input, meta) => { const out = await tools.execute(name, input, meta); collect(name, out); return out; } }
118
+ : tools;
119
+ return {
120
+ tools: wrapped,
121
+ list: () => [...sources.values()],
122
+ /** The answer with its `[n]` citations linked and a Sources section — or the answer untouched when nothing was retrieved. */
123
+ apply(answer) {
124
+ if (!sources.size || !answer || typeof answer !== 'string') return answer;
125
+ try { return linkifyCitations(answer, [...sources.values()]); } catch { return answer; }
126
+ },
127
+ };
128
+ }
package/client-prefs.js CHANGED
@@ -40,6 +40,10 @@ export const PREF_SECTIONS = Object.freeze([
40
40
  { id: 'watch', label: 'Watch', path: ['ui', 'watch'], kind: 'object' },
41
41
  { id: 'meetings', label: 'Meetings', path: null, kind: 'object', keys: ['meetingWindowMin', 'liveNotesIntervalMin', 'alertSound'] },
42
42
  { id: 'redaction', label: 'Redaction (client-side)', path: ['ui', 'piiRedaction'], kind: 'object' },
43
+ // Internal sites (source-gate.js): which hosts are internal and how far their content may
44
+ // travel. A rule the desktop enforces too, or a page internal in the panel is sent
45
+ // anywhere from the desk.
46
+ { id: 'internalSites', label: 'Internal sites', path: ['privacy'], kind: 'object' },
43
47
  ]);
44
48
 
45
49
  export const PREF_SECTION_IDS = Object.freeze(PREF_SECTIONS.map((s) => s.id));
package/index.js CHANGED
@@ -119,12 +119,12 @@ export {
119
119
  } from './observability.js';
120
120
  export { routeGraph, projectChain } from './route-graph.js';
121
121
  export { defineAdapter, createAdapterRegistry, AdapterError } from './adapters.js';
122
- export { linkifyCitations, sourcesFromToolText } from './citations.js';
122
+ export { linkifyCitations, sourcesFromToolText, createCitationCollector, RETRIEVAL_TOOLS } from './citations.js';
123
123
  export { buildTrajectory, phasesOf, lanesOf, filterEntries, displayName, ENTRY_KINDS, threadsOf, threadTitle, promptEntries, turnsOf, threadTree } from './trajectory.js';
124
124
  export { createTurnRunner, defineLoop, LOOP_KINDS, LoopError } from './loop.js';
125
125
  export { defineModel, defineMiddleware, defineRouteStrategy, createModelRouter, signalsFrom, requirementsFor, requirementsForStep, preferenceFor, failoverOrder, pinnedOrderOf, FAILOVER_CLASS_GAP, FAILOVER_CAPABILITY_GAP, sameModelKey, RouterError } from './router.js';
126
126
  export { makeSourceStore, manifestText, shortUrl, readSource, sourceId } from './sources-retrieval.js';
127
- export { classifySource, extractUrls, hostMatches, meetReach, sourcePolicyFor, DEFAULT_INTERNAL_PATTERNS, INTERNAL_PATTERN_CATALOG } from './sources.js';
127
+ export { classifySource, extractUrls, hostMatches, meetReach, sourcePolicyFor, sourceUrlsOf, DEFAULT_INTERNAL_PATTERNS, INTERNAL_PATTERN_CATALOG } from './sources.js';
128
128
  export { defineRule, createRuleEngine, SUPPRESSED, RuleError } from './rules.js';
129
129
  export {
130
130
  VAULT_VERSION, KDF_ITERATIONS, KDF_HASH, DEFAULT_LOCK_MS, VaultError,
@@ -182,6 +182,9 @@ export {
182
182
  // next model.
183
183
  export { classifyFailure, createModelHealth, COOLDOWN_MS, UNAVAILABLE_REASONS, normModelName } from './model-health.js';
184
184
  export { runWithFailover, failoverExhausted, FAILOVER_MAX_ATTEMPTS } from './failover.js';
185
+ // The source ceiling as a gate, and the citation collector — the last two pieces of the turn
186
+ // wrapper that were the extension's alone.
187
+ export { sourcePolicySettings, sourceGuardFor, sourceGate, isSourceGateError, withinReach } from './source-gate.js';
185
188
  export {
186
189
  runTurnLoop, createCallRunner, roundCap, withToolSystem, describeCall as describeToolCall, stepResultText, addUsage, normalizeUsage,
187
190
  openAiTranscript, anthropicTranscript,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.96.0",
3
+ "version": "0.97.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -88,6 +88,7 @@
88
88
  "./skill-vars.js": "./skill-vars.js",
89
89
  "./slash-commands.js": "./slash-commands.js",
90
90
  "./sources-retrieval.js": "./sources-retrieval.js",
91
+ "./source-gate.js": "./source-gate.js",
91
92
  "./sources.js": "./sources.js",
92
93
  "./store.js": "./store.js",
93
94
  "./structured.js": "./structured.js",
@@ -226,6 +227,7 @@
226
227
  "skill-vars.js",
227
228
  "slash-commands.js",
228
229
  "sources-retrieval.js",
230
+ "source-gate.js",
229
231
  "sources.js",
230
232
  "store.js",
231
233
  "structured.js",
package/source-gate.js ADDED
@@ -0,0 +1,92 @@
1
+ // The source ceiling — would this turn send internal material to a model that is too far
2
+ // away? — as a GATE, not a ranking.
3
+ //
4
+ // Routing is not enough. A router returns null in every uncertain case and null means
5
+ // "leave the choice alone", which is right for a preference and catastrophic for a privacy
6
+ // rule: an internal page with no local model available would have gone to the third party
7
+ // the user had selected. And routing only runs under Auto, so a manually chosen cloud model
8
+ // bypassed it entirely. So this runs on every turn, after routing has had its chance to
9
+ // pick something local, and it REFUSES rather than substituting: silently answering from a
10
+ // different model is the substitution this codebase keeps removing, and silently sending
11
+ // anyway is the leak it exists to stop.
12
+ //
13
+ // Was the extension's `sourceGate` + `sourcePolicySettings` + `sourceUrlsOf`; the desktop
14
+ // had none of it and sent internal pages wherever the picker pointed. The policy (which
15
+ // hosts are internal, how far their content may travel) is the user's and travels in the
16
+ // shared `internalSites` preference section, so both clients enforce the same rule.
17
+ //
18
+ // Class R: no I/O. The target's reach comes from `reachOf` (a bridge agent is 'trusted', a
19
+ // localhost endpoint 'device', the rest 'any') unless the caller already knows it.
20
+
21
+ import { sourcePolicyFor, sourceUrlsOf, DEFAULT_INTERNAL_PATTERNS } from './sources.js';
22
+ import { reachOf } from './model-candidates.js';
23
+ import { REACH, reachRank } from './reach.js';
24
+
25
+ // `sourceUrlsOf` lives in sources.js (no router on its graph) so a client's first paint can
26
+ // list a turn's addresses without pulling the router in; it is re-exported here for callers
27
+ // that have the gate anyway.
28
+ export { sourceUrlsOf };
29
+
30
+ /**
31
+ * The internal-source policy, read from the `privacy` settings object in ONE place.
32
+ *
33
+ * NEVER CONFIGURED and CONFIGURED TO NOTHING are different answers. Undefined means the
34
+ * user has not been here yet, so the built-ins apply; an array — even an empty one — is a
35
+ * list they edited, and prepending our own to it would make a default impossible to
36
+ * remove. Someone testing against localhost has a real reason to delete that line.
37
+ */
38
+ export function sourcePolicySettings(privacy = {}) {
39
+ const cfg = privacy || {};
40
+ const saved = cfg.internalPatterns;
41
+ const list = Array.isArray(saved)
42
+ ? saved
43
+ : (saved == null ? DEFAULT_INTERNAL_PATTERNS : String(saved).split(/[\s,]+/));
44
+ return {
45
+ enabled: cfg.internalGuard !== false,
46
+ patterns: list.map((x) => String(x || '').trim().toLowerCase()).filter(Boolean),
47
+ ceiling: cfg.internalCeiling === 'trusted' ? 'trusted' : 'device',
48
+ };
49
+ }
50
+
51
+ /** What the sources of a turn allow. Null when the guard is off or nothing matched. */
52
+ export function sourceGuardFor(policy, sources = []) {
53
+ if (!policy || !policy.enabled || !sources?.length) return null;
54
+ const p = sourcePolicyFor(sources, { patterns: policy.patterns, ceiling: policy.ceiling });
55
+ return p.internal ? p : null;
56
+ }
57
+
58
+ /**
59
+ * The gate. `{ blocked, why, reach, message }` when the target is out of reach for these
60
+ * sources; null when the turn may go.
61
+ *
62
+ * @param policy `sourcePolicySettings(privacy)`
63
+ * @param messages the conversation as it will be sent
64
+ * @param sources anything else the caller knows was attached
65
+ * @param target `{ kind, baseUrl }` (reach computed) or `{ reach }` (reach known)
66
+ * @param label how to name the target in the message
67
+ */
68
+ export function sourceGate({ policy, messages = [], sources = [], target = {}, label = '' } = {}) {
69
+ const guard = sourceGuardFor(policy, sourceUrlsOf(messages, sources));
70
+ if (!guard) return null;
71
+ const actual = REACH.includes(target?.reach) ? target.reach : reachOf(target || {});
72
+ if (reachRank(actual) <= reachRank(guard.reach)) return null;
73
+ const where = guard.reach === 'device' ? 'stay on this device' : 'stay inside your workspace';
74
+ return {
75
+ blocked: true,
76
+ why: guard.why,
77
+ reach: guard.reach,
78
+ // Name the source, the model and the way out. A refusal a person cannot act on gets
79
+ // switched off wholesale, which would leave them worse protected than before.
80
+ message: `Not sent: ${guard.why}. "${label || 'this model'}" is outside that, and content from an internal source must ${where}. `
81
+ + 'Pick a local model (or run one), or remove this site under Settings → Privacy → Internal sites.',
82
+ };
83
+ }
84
+
85
+ /** Is this error the gate's refusal? A runner reads it as "this model, not this task". */
86
+ export const isSourceGateError = (err) => /^Not sent: /.test(String(err?.message || err || ''));
87
+
88
+ /** The candidates a turn under `guard` may be handed at all — for a roster, before appointment. */
89
+ export function withinReach(candidates = [], guard = null) {
90
+ if (!guard) return candidates;
91
+ return (candidates || []).filter((c) => reachRank(REACH.includes(c?.reach) ? c.reach : reachOf(c || {})) <= reachRank(guard.reach));
92
+ }
package/sources.js CHANGED
@@ -254,3 +254,20 @@ export function sourcePolicyFor(sources = [], { patterns, ceiling = 'device', ba
254
254
  why: `${hits[0].host || 'the source'} matches '${hits[0].matched}' — kept ${safeCeiling === 'device' ? 'on this device' : 'inside your workspace'}`,
255
255
  };
256
256
  }
257
+
258
+ /**
259
+ * Every address a conversation carries: each attachment's `url`, every URL in a message
260
+ * body (an internal link pasted into a message — or arriving in a tool result written back
261
+ * into the conversation — is internal material just as much as an attachment is), plus
262
+ * anything the caller states outright. The WHOLE conversation, because an internal page
263
+ * attached three turns ago is still in the text being sent now.
264
+ */
265
+ export function sourceUrlsOf(messages, extraSources = []) {
266
+ const urls = [];
267
+ for (const m of messages || []) {
268
+ for (const a of m?.attachments || []) if (a?.url) urls.push(a.url);
269
+ for (const u of extractUrls(typeof m?.content === 'string' ? m.content : '')) urls.push(u);
270
+ }
271
+ for (const s of extraSources || []) if (s) urls.push(typeof s === 'string' ? s : (s.url || s.href || ''));
272
+ return urls.filter(Boolean);
273
+ }
package/team-record.js CHANGED
@@ -70,7 +70,10 @@ export function foldRun(run, ev) {
70
70
  if (t) { t.status = 'running'; t.startedAt = at; t.error = null; }
71
71
  run.status = 'running'; break;
72
72
  }
73
- case 'task.model': { const t = taskOf(run, p.taskId); if (t) { t.model = p.model; t.attempts = [...(t.attempts || []), { model: p.model, at, attempt: p.attempt }]; } break; }
73
+ case 'task.model': { const t = taskOf(run, p.taskId); if (t) { t.model = p.model; t.attempts = [...(t.attempts || []), { model: p.model, ...(p.label ? { label: p.label } : {}), at, attempt: p.attempt }]; } break; }
74
+ // Which engine the attempt runs on (pillars §13): kept on the attempt, so a record folded
75
+ // from events names the model the way the runner's own `attempts` do.
76
+ case 'task.routed': { const t = taskOf(run, p.taskId); const a = t?.attempts?.at?.(-1); if (a && p.engine && (a.attempt == null || a.attempt === p.attempt)) a.engine = p.engine; break; }
74
77
  case 'task.step': { const t = taskOf(run, p.taskId); if (t && Array.isArray(p.steps)) t.transcript = [...(t.transcript || []), ...p.steps]; break; }
75
78
  case 'task.handoff': { const t = taskOf(run, p.taskId); if (t) { t.model = p.to; t.handoffs = [...(t.handoffs || []), { from: p.from, to: p.to, by: p.by, reason: p.reason, at }]; } break; }
76
79
  case 'task.tool': { const t = taskOf(run, p.taskId); if (t) t.tools = (t.tools || 0) + 1; break; }
package/team-run.js CHANGED
@@ -69,6 +69,9 @@ export function isModelUnavailable(error) {
69
69
  // fetch" (the extension reports it as "network error") — a local model killed mid-run
70
70
  // arrived as the latter and ended the task on its first attempt with Claude Code sitting
71
71
  // idle on the roster.
72
+ // The source gate's refusal (source-gate.js) is about THIS model's reach, not the task:
73
+ // the next appointment, within reach, can do it.
74
+ if (/^Not sent: /.test(m)) return true;
72
75
  return /model[_ ]not[_ ]found|not found|not deployed|inaccessible|does not exist|no such model|unknown model|unsupported model|not available|unavailable|no api key|not configured|"status":\s*(404|401|403|500|502|503)\b|\b(404|401|403|502|503)\b|exited \d+|returned no answer|did not answer|closed the connection|couldn't reach|could not reach|ECONNREFUSED|ECONNRESET|ENOTFOUND|EHOSTUNREACH|socket hang up|fetch failed|failed to fetch|load failed|network ?error|overloaded|capacity/i.test(m);
73
76
  }
74
77
 
@@ -414,7 +417,9 @@ export async function runTeam({
414
417
  // A model that is not there — not deployed, no key, gone — is not the task failing:
415
418
  // the next model on the roster is appointed and the task tried again, up to three
416
419
  // models. Anything else (a refusal, a timeout, a bad request) fails the task.
417
- const exclude = new Set();
420
+ // A resumed task does not walk back into the models that were unavailable the last
421
+ // time it ran — that is how one task read `A → B → A → B → A → B` on the board.
422
+ const exclude = new Set((was?.attempts || []).filter((a) => a && a.model && a.status === 'error' && isModelUnavailable(a.error)).map((a) => a.model));
418
423
  let lastErr = '';
419
424
  // What the next attempt is told, when it continues rather than starts.
420
425
  let note = was ? continuationNote(was.status === 'waiting' ? { kind: 'answer', answer: 'see the board' } : { kind: 'resume', reason: was.error || was.status }) : null;
@@ -440,10 +445,12 @@ export async function runTeam({
440
445
  if (!m?.model) throw new Error(exclude.size ? `no model left for role "${role.id}" after ${[...exclude].join(', ')}` : `no model for role "${role.id}"`);
441
446
  if (attempt > 1 && lastErr) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude], error: lastErr });
442
447
  // Who is doing this task, for a ledger that shows the lanes — said per attempt.
443
- say('task.model', { taskId: task.id, role: role.id, model: m.model, attempt });
448
+ say('task.model', { taskId: task.id, role: role.id, model: m.model, label: m.label || null, attempt });
444
449
  routed = routeOf(m, role, { attempt, exclude, handoff: handoffNow });
445
450
  say('task.routed', { taskId: task.id, role: role.id, attempt, ...routed });
446
- attempts.push({ model: m.model, engine: routed.engine, at: now(), continued: !!note });
451
+ // The label beside the id: a work log that reads `mqk41ucyhmz1au → mqqzh4970js34c` names
452
+ // nothing to the person reading it.
453
+ attempts.push({ model: m.model, label: m.label || routed.engine?.label || routed.engine?.model || null, engine: routed.engine, at: now(), continued: !!note });
447
454
  attemptNo = attempts.length;
448
455
  const sent = messagesFor({ transcript }, { prompt, note });
449
456
  // The record grows AS THE ATTEMPT GOES: a host that reports each wire message the
@@ -525,7 +532,7 @@ export async function runTeam({
525
532
  // the board sees "researcher failed: network error" where it happened, and what was tried.
526
533
  if (thread && status === 'ok') board.setThreadStatus(thread.id, 'resolved');
527
534
  else if (thread && status !== 'waiting') {
528
- board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `${role?.id || task.role} ${status === 'over-budget' ? 'stopped at the budget' : 'failed'}${error ? `: ${String(error).slice(0, 300)}` : ''}${attempts.length > 1 ? ` (after ${attempts.length} models: ${attempts.map((a) => a.model).join(', ')})` : ''}.` });
535
+ board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `${role?.id || task.role} ${status === 'over-budget' ? 'stopped at the budget' : 'failed'}${error ? `: ${String(error).slice(0, 300)}` : ''}${attempts.length > 1 ? ` (after ${attempts.length} models: ${attempts.map((a) => a.label || a.model).join(', ')})` : ''}.` });
529
536
  board.setThreadStatus(thread.id, 'failed');
530
537
  }
531
538
  const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(task.parent ? { parent: task.parent } : {}), ...(routed ? { routed } : {}), ...(scm ? { scm } : {}), ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
package/team-worklog.js CHANGED
@@ -49,7 +49,7 @@ export function workLogFor(run, taskId) {
49
49
  const out = [];
50
50
  const attempts = Array.isArray(task.attempts) ? task.attempts : [];
51
51
  const baseAt = num(task.startedAt, num(attempts[0]?.at, num(run?.startedAt, 0)));
52
- attempts.forEach((a, i) => out.push({ id: `attempt:${i + 1}`, kind: 'attempt', at: num(a.at, baseAt + i), by: 'runner', attempt: i + 1, model: a.model || '', engine: a.engine || null, continued: !!a.continued, status: a.status || null, error: a.error || null }));
52
+ attempts.forEach((a, i) => out.push({ id: `attempt:${i + 1}`, kind: 'attempt', at: num(a.at, baseAt + i), by: 'runner', attempt: i + 1, model: a.label || a.engine?.label || a.engine?.model || a.model || '', modelId: a.model || '', engine: a.engine || null, continued: !!a.continued, status: a.status || null, error: a.error || null }));
53
53
 
54
54
  // Steps: stamped ones sort by their time; unstamped ones follow their attempt in order.
55
55
  const steps = Array.isArray(task.transcript) ? task.transcript : [];
@@ -101,7 +101,7 @@ export function workLogFor(run, taskId) {
101
101
  }
102
102
 
103
103
  function endText(task, attempts) {
104
- const tried = attempts.length > 1 ? ` after ${attempts.length} models (${attempts.map((a) => a.model).join(' → ')})` : '';
104
+ const tried = attempts.length > 1 ? ` after ${attempts.length} models (${attempts.map((a) => a.label || a.engine?.label || a.engine?.model || a.model).join(' → ')})` : '';
105
105
  const s = task.status;
106
106
  if (s === 'ok') return `done${tried} · ${task.findings || 0} finding${task.findings === 1 ? '' : 's'} · ${task.tools || 0} tool call${task.tools === 1 ? '' : 's'}`;
107
107
  if (s === 'waiting') return 'waiting on a person';