@chatpanel/events 0.102.0 → 0.103.1

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.
Files changed (138) hide show
  1. package/adapters.js +1 -83
  2. package/adaptive-tool-policy.js +1 -45
  3. package/agent.js +2 -255
  4. package/attribution.js +1 -132
  5. package/backup-envelope.js +1 -221
  6. package/board-tool.js +2 -165
  7. package/budget.js +1 -116
  8. package/capability.js +1 -126
  9. package/citations.js +5 -127
  10. package/client-prefs.js +1 -148
  11. package/context-attachments.js +8 -144
  12. package/cowriter-router.js +1 -83
  13. package/cowriter-writer.js +19 -193
  14. package/cowriter.js +1 -190
  15. package/curate.js +3 -509
  16. package/distance.js +1 -124
  17. package/engine.js +1 -131
  18. package/entitlement.js +1 -332
  19. package/entity.js +1 -354
  20. package/event.js +1 -175
  21. package/extraction.js +4 -293
  22. package/failover.js +1 -93
  23. package/find-tool.js +1 -66
  24. package/flowchart.js +2 -644
  25. package/gate.js +1 -74
  26. package/harness.js +4 -101
  27. package/index.js +1 -345
  28. package/invariants.js +1 -174
  29. package/job.js +3 -147
  30. package/kernel.js +1 -255
  31. package/knowledge-derive.js +1 -267
  32. package/knowledge.js +3 -230
  33. package/library.js +11 -280
  34. package/loop.js +1 -132
  35. package/manifest.js +1 -107
  36. package/markdown-authoring.js +11 -288
  37. package/markdown-render.js +0 -0
  38. package/mcp-client.js +8 -358
  39. package/mcp-dispatch.js +1 -52
  40. package/mcp-errors.js +1 -106
  41. package/mcp-manager.js +1 -127
  42. package/media-transcript.js +8 -531
  43. package/meeting-analyzers.js +1 -83
  44. package/meeting-insights.js +7 -164
  45. package/meeting-shape.js +1 -282
  46. package/meeting-text.js +5 -94
  47. package/memory.js +2 -638
  48. package/model-candidates.js +1 -358
  49. package/model-health.js +1 -182
  50. package/model-ledger.js +1 -228
  51. package/model-picker.js +1 -188
  52. package/note-actions.js +15 -246
  53. package/note-graph.js +1 -158
  54. package/note-links.js +1 -68
  55. package/note-mentions.js +8 -114
  56. package/note-plan.js +9 -107
  57. package/note-research.js +1 -135
  58. package/observability.js +1 -119
  59. package/omni.js +1 -125
  60. package/order.js +1 -78
  61. package/package.json +1 -4
  62. package/pdf-layout.js +7 -253
  63. package/project-run.js +20 -444
  64. package/project.js +1 -170
  65. package/promotion.js +1 -171
  66. package/queue.js +1 -98
  67. package/reach.js +1 -23
  68. package/recipe-tool.js +2 -161
  69. package/recipe.js +1 -264
  70. package/record-list.js +2 -110
  71. package/recruit.js +3 -456
  72. package/redaction-tokens.js +1 -61
  73. package/ref.js +1 -55
  74. package/registry.js +1 -240
  75. package/route-graph.js +1 -115
  76. package/route-strategies.js +2 -232
  77. package/router.js +2 -880
  78. package/rrf.js +1 -89
  79. package/rules.js +1 -142
  80. package/schedule.js +1 -663
  81. package/scm-connection.js +1 -180
  82. package/scopes.js +1 -12
  83. package/scorecard.js +1 -335
  84. package/search-engines.js +1 -81
  85. package/skill-manifest.js +1 -330
  86. package/skill-scan.js +3 -209
  87. package/skill-sources.js +1 -162
  88. package/skill-vars.js +2 -252
  89. package/slash-commands.js +1 -158
  90. package/source-gate.js +1 -92
  91. package/sources-retrieval.js +5 -186
  92. package/sources.js +1 -273
  93. package/store.js +1 -171
  94. package/structured.js +4 -901
  95. package/subject-kinds.js +1 -5
  96. package/subject-name.js +1 -96
  97. package/sync-plan.js +1 -213
  98. package/synthesis.js +3 -123
  99. package/tags.js +1 -203
  100. package/team-board.js +6 -337
  101. package/team-cache.js +2 -63
  102. package/team-observe.js +1 -118
  103. package/team-org.js +1 -442
  104. package/team-plan.js +4 -131
  105. package/team-record.js +1 -225
  106. package/team-run.js +9 -652
  107. package/team-subtask.js +2 -191
  108. package/team-task.js +2 -124
  109. package/team-tool.js +2 -196
  110. package/team-trail.js +2 -73
  111. package/team-worklog.js +2 -149
  112. package/team.js +1 -302
  113. package/text-search.js +1 -155
  114. package/theme.js +17 -154
  115. package/titles.js +4 -285
  116. package/tool-discovery.js +1 -87
  117. package/tool-dispatch.js +5 -230
  118. package/tool-groups.js +1 -81
  119. package/tool-hints.js +10 -185
  120. package/tool-loop-guard.js +4 -182
  121. package/tool-need.js +2 -124
  122. package/tool-result.js +2 -438
  123. package/tool-round.js +0 -0
  124. package/tool-schema.js +1 -155
  125. package/tool-traits.js +1 -182
  126. package/toolset.js +2 -88
  127. package/trajectory.js +1 -530
  128. package/turn-loop.js +4 -440
  129. package/upcast.js +1 -37
  130. package/vault.js +2 -250
  131. package/view.js +1 -88
  132. package/voice-intents.js +2 -1488
  133. package/voice-speaker.js +1 -98
  134. package/weather-tool.js +1 -65
  135. package/weather.js +2 -211
  136. package/web-search-tool.js +14 -102
  137. package/web-search.js +1 -160
  138. package/widget.js +1 -153
package/library.js CHANGED
@@ -1,284 +1,15 @@
1
- // THE LIBRARY one record model for chats, notes, meetings and briefs.
2
- //
3
- // WHY THIS IS IN THE SHARED PACKAGE. "What is a record" is currently answered three times
4
- // and the three answers already disagree:
5
- //
6
- // • the extension builds rich source records (conversationSource / meetingSource /
7
- // noteSource / briefSource), each with its own title, date and body rules;
8
- // • the gateway re-derives them in `backup-ingest.js`, whose own comment calls it "a
9
- // SIMPLIFIED MIRROR of the extension's" — a mirror is a copy that drifts;
10
- // • a desktop or mobile client would make a third, and three implementations of "what is
11
- // the title of a meeting" become three different titles for the same meeting.
12
- //
13
- // The projection is pure: records in, a normalized record out. No storage, no crypto, no
14
- // platform. Each host keeps its own persistence (IndexedDB, SQLite, Core Data) and calls
15
- // this to decide WHAT it is storing — the P8 split the store already uses.
16
- //
17
- // THE ID GRAMMAR IS THE CONTRACT. `chat:<id>`, `note:<id>`, `meeting:<id>`, `brief:<id>`.
18
- // The gateway's warm index, the MCP tools, `find_related`, the omni bar and every citation
19
- // already speak it; writing it down here is what stops a fourth client inventing
20
- // `chats/<id>` and silently failing to join.
21
- //
22
- // NAMED `normalizeStoredRecord`, NOT `normalizeStoredRecord`, on purpose: `curate.js` already
23
- // exports the latter and it does the OTHER job — coercing anything into the flat survey
24
- // shape a curation pass reads. Two functions named the same in one package, one lossy and
25
- // one lossless, is a trap for whoever imports the wrong one.
26
- //
27
- // LOSSY AND LOSSLESS ARE DIFFERENT QUESTIONS, and conflating them is what made the gateway's
28
- // mirror drift. `toSearchRecord` is deliberately lossy — it is the flat {id,title,type,date,
29
- // text} the BM25 index wants. `normalizeStoredRecord` is lossless — it is the full record a client
30
- // stores and round-trips. A client that needs to SHOW a chat must never reconstruct it from
31
- // a search record.
1
+ class s extends Error{constructor(e){super(e),this.name="LibraryError"}}const m=Object.freeze(["chat","note","meeting","brief"]),l=new Set(m);function u(t){const e=String(t||""),n=e.indexOf(":");if(n<=0)return null;const r=e.slice(0,n),o=e.slice(n+1);return!l.has(r)||!o?null:{kind:r,localId:o}}function S(t,e){if(!l.has(t))throw new s(`unknown record kind: ${t}`);const n=String(e||"");if(!n)throw new s("a record id needs a local id");if(n.includes(":"))throw new s(`local id may not contain ":" — got ${n}`);return`${t}:${n}`}function T(t){return u(t)!==null}const f=48;function p(t,e="Untitled"){const n=String(t||"").split(`
2
+ `).map(r=>r.replace(/^\s*#{1,6}\s*/,"").replace(/[*_`>~]+/g,"").trim()).find(r=>r.length>0);return n?n.length>f?`${n.slice(0,f-1).trimEnd()}…`:n:e}const d=(t,e=0)=>Number.isFinite(Number(t))?Number(t):e,i=t=>typeof t=="string"?t:t==null?"":String(t);function c(t={},{now:e=0}={}){const n=u(t.id);if(!n)throw new s(`not a record id: ${JSON.stringify(t.id)}`);const r=d(t.createdAt,d(t.date,e));return{id:t.id,kind:n.kind,localId:n.localId,title:i(t.title).trim()||x(n.kind,t),tags:y(t.tags),createdAt:r,updatedAt:d(t.updatedAt,r),deletedAt:d(t.deletedAt,0)||0,body:t.body??null,meta:t.meta&&typeof t.meta=="object"?t.meta:{}}}function x(t,e){return t==="note"?p(e?.body?.markdown??e?.body,"Untitled note"):t==="meeting"?"Untitled meeting":t==="brief"?"Untitled brief":"New chat"}function y(t){if(!Array.isArray(t))return[];const e=[];for(const n of t){const r=i(n).trim();r&&!e.includes(r)&&e.push(r)}return e}function I(t){try{return c(t),!0}catch{return!1}}const A={assistant:"Assistant",system:"System",user:"You"};function L(t){const e=c(t);return e.deletedAt?null:{id:e.id,type:e.kind,title:e.title,date:e.updatedAt||e.createdAt,text:h(e)}}function h(t){const e=t.kind?t:c(t),n=`${b(e.kind)}: ${e.title}`,r=e.tags.length?`
3
+ Tags: ${e.tags.join(", ")}`:"";return`${n}${r}
32
4
 
33
- export class LibraryError extends Error {
34
- constructor(message) { super(message); this.name = 'LibraryError'; }
35
- }
5
+ ${g(e)}`.trim()}function b(t){return t==="chat"?"CHAT":t==="note"?"NOTE":t==="meeting"?"MEETING":"BRIEF"}function g(t){const e=t.body;if(e==null)return"";if(typeof e=="string")return e;const n=typeof e.text=="string"?e.text:"";if(t.kind==="chat")return(e.messages||[]).filter(o=>o&&o.content).map(o=>`${A[o.role]||"You"}: ${E(o.content)}`).join(`
36
6
 
37
- /** The four record kinds. Ordered as the UI orders them, not alphabetically. */
38
- export const RECORD_KINDS = Object.freeze(['chat', 'note', 'meeting', 'brief']);
7
+ `)||n;if(t.kind==="note")return i(e.markdown??e.text);if(t.kind==="meeting"){const r=i(e.notes),o=(e.segments||[]).map(a=>`${i(a.speaker)||"?"}: ${i(a.text)}`).join(`
8
+ `);return[r,o].filter(Boolean).join(`
39
9
 
40
- const KIND_SET = new Set(RECORD_KINDS);
10
+ `)||n}if(t.kind==="brief"){const r=(e.claims||[]).map(o=>i(o.text)).filter(Boolean).join(`
11
+ `);return[i(e.summary),r].filter(Boolean).join(`
41
12
 
42
- /** `chat:abc` { kind:'chat', localId:'abc' }; anything else null. */
43
- export function parseRecordId(recordId) {
44
- const s = String(recordId || '');
45
- const colon = s.indexOf(':');
46
- if (colon <= 0) return null;
47
- const kind = s.slice(0, colon);
48
- const localId = s.slice(colon + 1);
49
- if (!KIND_SET.has(kind) || !localId) return null;
50
- return { kind, localId };
51
- }
52
-
53
- /** The inverse. Throws rather than producing an id nothing else will match. */
54
- export function makeRecordId(kind, localId) {
55
- if (!KIND_SET.has(kind)) throw new LibraryError(`unknown record kind: ${kind}`);
56
- const id = String(localId || '');
57
- if (!id) throw new LibraryError('a record id needs a local id');
58
- if (id.includes(':')) throw new LibraryError(`local id may not contain ":" — got ${id}`);
59
- return `${kind}:${id}`;
60
- }
61
-
62
- export function isRecordId(value) {
63
- return parseRecordId(value) !== null;
64
- }
65
-
66
- // --------------------------------------------------------------------------
67
- // Titles
68
- // --------------------------------------------------------------------------
69
-
70
- /** The single length a record title may be — the extension's MAX_TITLE_LEN, shared. */
71
- export const MAX_TITLE_LEN = 48;
72
-
73
- /**
74
- * First non-empty line, stripped of markdown furniture, clamped.
75
- *
76
- * Notes derive their title from the body when the user has not set one, and the meeting
77
- * autotitler falls back here too. Keeping it in one place is what stops "# Rollback runbook"
78
- * becoming the title in one client and "Rollback runbook" in another.
79
- */
80
- export function deriveTitle(body, fallback = 'Untitled') {
81
- const first = String(body || '')
82
- .split('\n')
83
- .map((l) => l.replace(/^\s*#{1,6}\s*/, '').replace(/[*_`>~]+/g, '').trim())
84
- .find((l) => l.length > 0);
85
- if (!first) return fallback;
86
- return first.length > MAX_TITLE_LEN ? `${first.slice(0, MAX_TITLE_LEN - 1).trimEnd()}…` : first;
87
- }
88
-
89
- // --------------------------------------------------------------------------
90
- // Normalization — the lossless shape
91
- // --------------------------------------------------------------------------
92
-
93
- const num = (v, dflt = 0) => (Number.isFinite(Number(v)) ? Number(v) : dflt);
94
- const str = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
95
-
96
- /**
97
- * A stored record, whatever client wrote it.
98
- *
99
- * `body` is the kind-specific payload and is carried VERBATIM — messages for a chat,
100
- * segments for a meeting, markdown for a note. This module refuses to flatten it, because
101
- * flattening is exactly the lossy step that belongs in `toSearchRecord` and nowhere else.
102
- */
103
- export function normalizeStoredRecord(input = {}, { now = 0 } = {}) {
104
- const parsed = parseRecordId(input.id);
105
- if (!parsed) throw new LibraryError(`not a record id: ${JSON.stringify(input.id)}`);
106
- const createdAt = num(input.createdAt, num(input.date, now));
107
- return {
108
- id: input.id,
109
- kind: parsed.kind,
110
- localId: parsed.localId,
111
- title: str(input.title).trim() || defaultTitleFor(parsed.kind, input),
112
- tags: normalizeTagList(input.tags),
113
- createdAt,
114
- // A record with no updatedAt sorts by when it was made, not to the bottom of the list.
115
- updatedAt: num(input.updatedAt, createdAt),
116
- // Soft delete. A removal has to be REPRESENTABLE or it cannot replicate: a record that
117
- // is merely absent from one side is indistinguishable from one that side has not seen.
118
- deletedAt: num(input.deletedAt, 0) || 0,
119
- body: input.body ?? null,
120
- meta: input.meta && typeof input.meta === 'object' ? input.meta : {},
121
- };
122
- }
123
-
124
- function defaultTitleFor(kind, input) {
125
- if (kind === 'note') return deriveTitle(input?.body?.markdown ?? input?.body, 'Untitled note');
126
- if (kind === 'meeting') return 'Untitled meeting';
127
- if (kind === 'brief') return 'Untitled brief';
128
- return 'New chat';
129
- }
130
-
131
- /**
132
- * Tags are a shared vocabulary — `tags.js` owns the rules. This is the shallow guard for
133
- * callers that hand us junk; it deliberately does NOT re-implement normalization, so a
134
- * client that cares passes tags through `normalizeTags` first.
135
- */
136
- function normalizeTagList(tags) {
137
- if (!Array.isArray(tags)) return [];
138
- const out = [];
139
- for (const t of tags) {
140
- const s = str(t).trim();
141
- if (s && !out.includes(s)) out.push(s);
142
- }
143
- return out;
144
- }
145
-
146
- export function isValidStoredRecord(rec) {
147
- try { normalizeStoredRecord(rec); return true; } catch { return false; }
148
- }
149
-
150
- // --------------------------------------------------------------------------
151
- // Projection — the lossy shape the search index wants
152
- // --------------------------------------------------------------------------
153
-
154
- const ROLE_LABEL = { assistant: 'Assistant', system: 'System', user: 'You' };
155
-
156
- /**
157
- * A record → the flat `{ id, type, title, date, text }` the warm index stores.
158
- *
159
- * This is the function the gateway's `backupToRecords` was a copy of. Note what it does NOT
160
- * do: it never invents an id, never guesses a kind, and never returns a record for something
161
- * it does not understand — a silent wrong answer in a search index is worse than a gap,
162
- * because the gap is visible.
163
- */
164
- export function toSearchRecord(record) {
165
- const rec = normalizeStoredRecord(record);
166
- if (rec.deletedAt) return null;
167
- return {
168
- id: rec.id,
169
- type: rec.kind,
170
- title: rec.title,
171
- date: rec.updatedAt || rec.createdAt,
172
- text: searchTextFor(rec),
173
- };
174
- }
175
-
176
- /** The body of a record as one searchable string. Exported because callers index in batches. */
177
- export function searchTextFor(record) {
178
- const rec = record.kind ? record : normalizeStoredRecord(record);
179
- const head = `${kindLabel(rec.kind)}: ${rec.title}`;
180
- const tagLine = rec.tags.length ? `\nTags: ${rec.tags.join(', ')}` : '';
181
- return `${head}${tagLine}\n\n${bodyText(rec)}`.trim();
182
- }
183
-
184
- function kindLabel(kind) {
185
- return kind === 'chat' ? 'CHAT' : kind === 'note' ? 'NOTE' : kind === 'meeting' ? 'MEETING' : 'BRIEF';
186
- }
187
-
188
- function bodyText(rec) {
189
- const b = rec.body;
190
- if (b == null) return '';
191
- if (typeof b === 'string') return b;
192
-
193
- // A record may arrive FLATTENED rather than structured — the gateway's warm index stores
194
- // `{ text }` for every kind, with no message list and no markdown. Falling through the
195
- // kind-specific branches would project it to the empty string, which is worse than it
196
- // sounds: the record is stored, listed and counted, but is invisible to search and shows
197
- // an empty preview. So a flat `text` is honoured for any kind, and the structured
198
- // extraction below wins whenever it actually finds something.
199
- const flat = typeof b.text === 'string' ? b.text : '';
200
-
201
- if (rec.kind === 'chat') {
202
- const turns = (b.messages || [])
203
- .filter((m) => m && m.content)
204
- .map((m) => `${ROLE_LABEL[m.role] || 'You'}: ${textOfContent(m.content)}`)
205
- .join('\n\n');
206
- return turns || flat;
207
- }
208
- if (rec.kind === 'note') return str(b.markdown ?? b.text);
209
- if (rec.kind === 'meeting') {
210
- const notes = str(b.notes);
211
- const segs = (b.segments || [])
212
- .map((s) => `${str(s.speaker) || '?'}: ${str(s.text)}`)
213
- .join('\n');
214
- return [notes, segs].filter(Boolean).join('\n\n') || flat;
215
- }
216
- if (rec.kind === 'brief') {
217
- const claims = (b.claims || []).map((c) => str(c.text)).filter(Boolean).join('\n');
218
- return [str(b.summary), claims].filter(Boolean).join('\n\n') || flat;
219
- }
220
- return flat;
221
- }
222
-
223
- /**
224
- * A message's content may be a string or a multimodal part list. An image part contributes
225
- * nothing to a text index, and stringifying the object would put `[object Object]` into the
226
- * corpus — which is not a hypothetical, it is what naive JSON handling does here.
227
- */
228
- function textOfContent(content) {
229
- if (typeof content === 'string') return content;
230
- if (!Array.isArray(content)) return '';
231
- return content
232
- .map((part) => (typeof part === 'string' ? part : part && part.type === 'text' ? str(part.text) : ''))
233
- .filter(Boolean)
234
- .join(' ');
235
- }
236
-
237
- // --------------------------------------------------------------------------
238
- // Counting — so every surface reports the same number
239
- // --------------------------------------------------------------------------
240
-
241
- /** Words in a note body, by the same rule everywhere. */
242
- export function wordCount(text) {
243
- const t = str(text).trim();
244
- return t ? t.split(/\s+/).length : 0;
245
- }
246
-
247
- /**
248
- * Header lines the warm text form carries under its title — `Date: …`, `Platform: meet` —
249
- * which are metadata, not the preview a row wants. A row already shows the date; a snippet
250
- * that repeats it says nothing. `You:` / a speaker's name are NOT in this set on purpose:
251
- * those lines are the content.
252
- */
253
- const META_LINE_RE = /^(Date|Platform|Model|Agent|Tags|Source|Kind|Created|Updated|Started|Ended|Duration|Participants|Attendees|URL|Link):\s/i;
254
-
255
- /** A short preview for a list row, so a list renders without opening every body. */
256
- export function snippetOf(text, max = 110) {
257
- const lines = str(text).split('\n');
258
- if (lines.length === 1) return lines[0].replace(/[#*_`>~]+/g, '').replace(/\s+/g, ' ').trim().slice(0, max);
259
- let i = 1; // the first line is the title
260
- while (i < lines.length && (!lines[i].trim() || META_LINE_RE.test(lines[i].trim()))) i += 1;
261
- const rest = lines.slice(i).join('\n');
262
- return rest.replace(/[#*_`>~]+/g, '').replace(/\s+/g, ' ').trim().slice(0, max);
263
- }
264
-
265
- /**
266
- * The index entry a list view renders from — everything needed to draw a row and nothing
267
- * that requires reading the body a second time.
268
- */
269
- export function toIndexEntry(record) {
270
- const rec = normalizeStoredRecord(record);
271
- const text = bodyText(rec);
272
- return {
273
- id: rec.id,
274
- kind: rec.kind,
275
- title: rec.title,
276
- tags: rec.tags,
277
- snippet: snippetOf(text),
278
- words: wordCount(text),
279
- chars: text.length,
280
- createdAt: rec.createdAt,
281
- updatedAt: rec.updatedAt,
282
- deletedAt: rec.deletedAt,
283
- };
284
- }
13
+ `)||n}return n}function E(t){return typeof t=="string"?t:Array.isArray(t)?t.map(e=>typeof e=="string"?e:e&&e.type==="text"?i(e.text):"").filter(Boolean).join(" "):""}function w(t){const e=i(t).trim();return e?e.split(/\s+/).length:0}const $=/^(Date|Platform|Model|Agent|Tags|Source|Kind|Created|Updated|Started|Ended|Duration|Participants|Attendees|URL|Link):\s/i;function k(t,e=110){const n=i(t).split(`
14
+ `);if(n.length===1)return n[0].replace(/[#*_`>~]+/g,"").replace(/\s+/g," ").trim().slice(0,e);let r=1;for(;r<n.length&&(!n[r].trim()||$.test(n[r].trim()));)r+=1;return n.slice(r).join(`
15
+ `).replace(/[#*_`>~]+/g,"").replace(/\s+/g," ").trim().slice(0,e)}function R(t){const e=c(t),n=g(e);return{id:e.id,kind:e.kind,title:e.title,tags:e.tags,snippet:k(n),words:w(n),chars:n.length,createdAt:e.createdAt,updatedAt:e.updatedAt,deletedAt:e.deletedAt}}export{s as LibraryError,f as MAX_TITLE_LEN,m as RECORD_KINDS,p as deriveTitle,T as isRecordId,I as isValidStoredRecord,S as makeRecordId,c as normalizeStoredRecord,u as parseRecordId,h as searchTextFor,k as snippetOf,R as toIndexEntry,L as toSearchRecord,w as wordCount};
package/loop.js CHANGED
@@ -1,132 +1 @@
1
- // The loop contract turn lifetime belongs to the kernel, not to the loop.
2
- //
3
- // We already run four loops: chat, meeting scribe, watch, and the notes swarm. They were
4
- // never expressed once; each surface grew its own, and the shared parts were copied. On
5
- // 2026-08-16 that produced a real bug — `streamChat` opened a turn, one exit path
6
- // returned above the close, and every finished note reported itself as still running.
7
- //
8
- // The fix that day was to route both exits through one helper. The fix here is that
9
- // there is nothing to route: a loop's `run` receives a context with NO way to open or
10
- // close a turn. The runner opens before calling it and closes in `finally`, so "forgot to
11
- // close on one path" is not a mistake a loop author is able to make. That is the
12
- // difference between a bug fixed and a bug class removed.
13
- //
14
- // The runner takes `now`/`newId` rather than reading a clock, matching event.js: this
15
- // package stays pure so the identical code runs in the extension, gateway and bridge, and
16
- // so replay is reproducible (order.js never consults wall time; I6 asserts it).
17
-
18
- export const LOOP_KINDS = Object.freeze(['chat', 'note', 'meeting', 'watch', 'assist', 'suggestion', 'topics', 'other']);
19
-
20
- export class LoopError extends Error {
21
- constructor(code, message) { super(message); this.name = 'LoopError'; this.code = code; }
22
- }
23
-
24
- /**
25
- * Declare a loop. A declaration is inert — it names a kind and supplies `run`; the runner
26
- * supplies everything about when a turn exists.
27
- */
28
- export function defineLoop({ id, kind = 'other', run, background = false }) {
29
- if (!id) throw new LoopError('BAD_LOOP', 'loop.id required');
30
- if (typeof run !== 'function') throw new LoopError('BAD_LOOP', `loop '${id}': run required`);
31
- return Object.freeze({ id, kind, run, background: !!background });
32
- }
33
-
34
- /**
35
- * @param emit `(type, payload)` — the host maps these to the event schema. Kept as a
36
- * plain hook so this module has no dependency on the log, matching registry.js.
37
- * @param decide optional `(guard, request) => decision` — normally `kernel.decide`. A
38
- * security plugin can refuse a turn before any model call happens, which is
39
- * the whole reason guards exist below the loop rather than inside it.
40
- */
41
- export function createTurnRunner({ now = () => 0, newId, emit = () => {}, decide = null } = {}) {
42
- if (typeof newId !== 'function') throw new LoopError('BAD_RUNNER', 'newId required — the runner must not invent identity');
43
-
44
- /**
45
- * Run one turn of `loop`. Returns whatever `run` returned.
46
- *
47
- * `request.turnId` lets a caller supply identity it already has (the side panel's
48
- * assistant-message id), so the turn record and the tool events that reference it group
49
- * into ONE run rather than two — the mismatch that split runs in Activity.
50
- */
51
- async function run(loop, request = {}) {
52
- if (!loop || typeof loop.run !== 'function') throw new LoopError('BAD_LOOP', 'runner.run needs a loop with run()');
53
-
54
- const turnId = request.turnId || newId();
55
- const kind = request.kind || loop.kind || 'other';
56
-
57
- // Ask BEFORE opening a turn: a denied turn should leave no trace of having started,
58
- // and a guard that only ran after the model call would be documentation, not a guard.
59
- if (decide) {
60
- const d = decide('turn.start', { turnId, kind, loopId: loop.id, ...request });
61
- if (d && d.allow === false) {
62
- emit('policy.denied', { turnId, kind, loopId: loop.id, reasons: d.reasons || [] });
63
- throw new LoopError('DENIED', `turn denied: ${(d.reasons || []).join('; ') || 'policy'}`);
64
- }
65
- }
66
-
67
- // The turn began when the USER acted, not when the model call did. Everything between
68
- // — assembling tools, connecting to MCP servers — is time they waited, and a duration
69
- // that excludes it says 2.6s about a message that took 48. Callers that know the real
70
- // moment pass it; the rest fall back to now.
71
- const startedAt = Number.isFinite(request.startedAt) ? request.startedAt : now();
72
- let closed = false;
73
- // Facts the loop learns while running — token usage, the model that actually served
74
- // it — belong on the turn record. The loop may CONTRIBUTE them; it still cannot
75
- // decide when the turn ends. Handing over the payload is not the same as handing over
76
- // the lifetime, and only the second one was ever the problem.
77
- let reported = {};
78
- // The ONE place a turn closes. Idempotent because a retry path or a double-catch must
79
- // not be able to write two endings for one turn — a log that can say a turn ended
80
- // twice cannot be replayed.
81
- const close = (reason, produced) => {
82
- if (closed) return;
83
- closed = true;
84
- emit('turn.ended', { ...reported, turnId, reason, stepped: !!produced, ms: now() - startedAt, kind });
85
- };
86
-
87
- emit('turn.started', {
88
- turnId, kind, loopId: loop.id,
89
- agentId: request.agentId || null,
90
- // WHICH THREAD THIS BELONGS TO. A run on its own is not the unit anyone reasons about —
91
- // a conversation is, and a meeting or a note can hold many runs (live monitors,
92
- // summaries, a swarm of agents). Without both halves the log is 1,205 unrelated rows.
93
- surface: request.surface || null,
94
- sourceId: request.sourceId || null,
95
- background: request.background ?? loop.background,
96
- });
97
-
98
- let produced = false;
99
- try {
100
- // NOTE what this context does NOT contain: no close, no end, no turn handle. A loop
101
- // cannot leave its own turn open because it was never given the ability to close it.
102
- const result = await loop.run({
103
- turnId,
104
- kind,
105
- signal: request.signal,
106
- request,
107
- /** Report that something reached the user — this is a fact about the turn, not control over it. */
108
- produced: () => { produced = true; },
109
- /**
110
- * Contribute facts to the turn record (tokens, model, cost). Merged into
111
- * `turn.ended`, and never able to overwrite the fields the runner owns — a loop
112
- * that could rewrite its own turnId or reason would be holding lifetime again by
113
- * another name.
114
- */
115
- report: (fields) => { if (fields && typeof fields === 'object') reported = { ...reported, ...fields }; },
116
- /** Loops emit their own domain events; lifetime is still not theirs. */
117
- emit: (type, payload) => emit(type, { turnId, ...payload }),
118
- });
119
- if (result !== undefined && result !== null && result !== '') produced = true;
120
- close(request.signal?.aborted ? 'aborted' : 'ok', produced);
121
- return result;
122
- } catch (err) {
123
- // An abort is not a failure: the user asking it to stop and the loop breaking are
124
- // different facts, and collapsing them makes the log useless for the question people
125
- // actually ask ("did it fail, or did I stop it?").
126
- close(request.signal?.aborted ? 'aborted' : 'error', produced);
127
- throw err;
128
- }
129
- }
130
-
131
- return { run };
132
- }
1
+ const I=Object.freeze(["chat","note","meeting","watch","assist","suggestion","topics","other"]);class c extends Error{constructor(i,t){super(t),this.name="LoopError",this.code=i}}function O({id:o,kind:i="other",run:t,background:s=!1}){if(!o)throw new c("BAD_LOOP","loop.id required");if(typeof t!="function")throw new c("BAD_LOOP",`loop '${o}': run required`);return Object.freeze({id:o,kind:i,run:t,background:!!s})}function y({now:o=()=>0,newId:i,emit:t=()=>{},decide:s=null}={}){if(typeof i!="function")throw new c("BAD_RUNNER","newId required — the runner must not invent identity");async function w(e,n={}){if(!e||typeof e.run!="function")throw new c("BAD_LOOP","runner.run needs a loop with run()");const u=n.turnId||i(),a=n.kind||e.kind||"other";if(s){const r=s("turn.start",{turnId:u,kind:a,loopId:e.id,...n});if(r&&r.allow===!1)throw t("policy.denied",{turnId:u,kind:a,loopId:e.id,reasons:r.reasons||[]}),new c("DENIED",`turn denied: ${(r.reasons||[]).join("; ")||"policy"}`)}const g=Number.isFinite(n.startedAt)?n.startedAt:o();let p=!1,l={};const h=(r,d)=>{p||(p=!0,t("turn.ended",{...l,turnId:u,reason:r,stepped:!!d,ms:o()-g,kind:a}))};t("turn.started",{turnId:u,kind:a,loopId:e.id,agentId:n.agentId||null,surface:n.surface||null,sourceId:n.sourceId||null,background:n.background??e.background});let f=!1;try{const r=await e.run({turnId:u,kind:a,signal:n.signal,request:n,produced:()=>{f=!0},report:d=>{d&&typeof d=="object"&&(l={...l,...d})},emit:(d,b)=>t(d,{turnId:u,...b})});return r!=null&&r!==""&&(f=!0),h(n.signal?.aborted?"aborted":"ok",f),r}catch(r){throw h(n.signal?.aborted?"aborted":"error",f),r}}return{run:w}}export{I as LOOP_KINDS,c as LoopError,y as createTurnRunner,O as defineLoop};
package/manifest.js CHANGED
@@ -1,107 +1 @@
1
- // What is installed, and what the user has switched off.
2
- //
3
- // Four registries now exist — adapters, tool groups, search engines, sources — and each is
4
- // small and correct on its own. What none of them can answer is the question a user asks:
5
- // "what is running, and can I turn that off?" Answering it in four places would be the
6
- // duplication that already bit us with the engine list, one level up.
7
- //
8
- // So the KERNEL owns the manifest and the registries consult it. This is admission control,
9
- // not a rewrite: a registry keeps its own shape and simply refuses to offer something the
10
- // user has disabled. It is also the seam that makes user-contributed plugins possible —
11
- // once a plugin can arrive from outside, "is this allowed to run" must be asked somewhere
12
- // that is not the plugin itself.
13
- //
14
- // Persistence is INJECTED. Where the toggles live is a platform question (chrome.storage,
15
- // a file, a database) and the manifest has no business knowing.
16
-
17
- export class ManifestError extends Error {
18
- constructor(code, message) { super(message); this.name = 'ManifestError'; this.code = code; }
19
- }
20
-
21
- /** Where a plugin came from. `user` is the one that must never skip a guard. */
22
- export const SOURCES = Object.freeze(['built-in', 'user']);
23
-
24
- /**
25
- * @param required ids that cannot be disabled, whatever the stored state says. Security is
26
- * the canonical member: a mandatory plugin the user can switch off is not mandatory,
27
- * it is a default.
28
- * @param disabled the user's stored choices — ids they have turned OFF. Stored as the
29
- * exception rather than the full state on purpose: a plugin added in a later release
30
- * is then enabled by default without a migration, because absence means "not
31
- * disabled" rather than "unknown".
32
- */
33
- export function createManifest({ required = ['security'], disabled = [], onChange = null } = {}) {
34
- const entries = new Map();
35
- const off = new Set(disabled);
36
- const req = new Set(required);
37
-
38
- const notify = () => { if (onChange) onChange([...off].sort()); };
39
-
40
- return {
41
- /**
42
- * Declare something installed. Idempotent, so a registry can register on every build
43
- * without accumulating duplicates.
44
- */
45
- register({ id, kind, label, source = 'built-in', description = '' }) {
46
- if (!id) throw new ManifestError('BAD_ENTRY', 'plugin id required');
47
- if (!SOURCES.includes(source)) throw new ManifestError('BAD_ENTRY', `plugin '${id}': unknown source '${source}'`);
48
- entries.set(id, { id, kind: kind || 'plugin', label: label || id, source, description });
49
- return () => entries.delete(id);
50
- },
51
-
52
- /**
53
- * The question every registry asks. Unknown ids are ENABLED: a registry may consult the
54
- * manifest before anything has registered, and defaulting to off would make a plugin
55
- * silently vanish because of a load-order accident.
56
- */
57
- isEnabled(id) {
58
- if (req.has(id)) return true;
59
- return !off.has(id);
60
- },
61
-
62
- /** Turn something on or off. Required plugins refuse rather than reporting success. */
63
- setEnabled(id, enabled) {
64
- if (req.has(id) && !enabled) {
65
- throw new ManifestError('REQUIRED', `'${id}' is required and cannot be disabled`);
66
- }
67
- const was = !off.has(id);
68
- if (enabled) off.delete(id); else off.add(id);
69
- if (was !== !!enabled) notify();
70
- return this.isEnabled(id);
71
- },
72
-
73
- /** Everything installed, with its current state — what a settings page renders. */
74
- list() {
75
- return [...entries.values()]
76
- .map((e) => ({ ...e, enabled: this.isEnabled(e.id), required: req.has(e.id) }))
77
- .sort((a, b) => (a.kind === b.kind ? a.label.localeCompare(b.label) : a.kind.localeCompare(b.kind)));
78
- },
79
-
80
- /** Only the ids that are OFF — the shape that persists (see the note above). */
81
- disabledIds: () => [...off].sort(),
82
-
83
- /**
84
- * Adopt state changed elsewhere.
85
- *
86
- * A manifest is not a fact read once at startup: the user toggles a plugin in one place
87
- * (a settings page, another window, another device) and every OTHER place holding a
88
- * manifest is now wrong. Without this, a toggle appears to do nothing until reload —
89
- * which reads as the switch being broken.
90
- *
91
- * Deliberately does NOT notify: this is the arrival of someone else's change, and
92
- * echoing it back is how two contexts write over each other forever.
93
- */
94
- sync(ids) {
95
- const next = new Set(Array.isArray(ids) ? ids : []);
96
- if (next.size === off.size && [...next].every((id) => off.has(id))) return false;
97
- off.clear();
98
- for (const id of next) off.add(id);
99
- return true;
100
- },
101
-
102
- /** Filter a registry's candidates. The one call a registry needs to make. */
103
- filter(items, idOf = (x) => x?.id) {
104
- return (items || []).filter((x) => this.isEnabled(idOf(x)));
105
- },
106
- };
107
- }
1
+ class u extends Error{constructor(i,s){super(s),this.name="ManifestError",this.code=i}}const h=Object.freeze(["built-in","user"]);function E({required:c=["security"],disabled:i=[],onChange:s=null}={}){const a=new Map,n=new Set(i),l=new Set(c),f=()=>{s&&s([...n].sort())};return{register({id:e,kind:r,label:t,source:o="built-in",description:d=""}){if(!e)throw new u("BAD_ENTRY","plugin id required");if(!h.includes(o))throw new u("BAD_ENTRY",`plugin '${e}': unknown source '${o}'`);return a.set(e,{id:e,kind:r||"plugin",label:t||e,source:o,description:d}),()=>a.delete(e)},isEnabled(e){return l.has(e)?!0:!n.has(e)},setEnabled(e,r){if(l.has(e)&&!r)throw new u("REQUIRED",`'${e}' is required and cannot be disabled`);const t=!n.has(e);return r?n.delete(e):n.add(e),t!==!!r&&f(),this.isEnabled(e)},list(){return[...a.values()].map(e=>({...e,enabled:this.isEnabled(e.id),required:l.has(e.id)})).sort((e,r)=>e.kind===r.kind?e.label.localeCompare(r.label):e.kind.localeCompare(r.kind))},disabledIds:()=>[...n].sort(),sync(e){const r=new Set(Array.isArray(e)?e:[]);if(r.size===n.size&&[...r].every(t=>n.has(t)))return!1;n.clear();for(const t of r)n.add(t);return!0},filter(e,r=t=>t?.id){return(e||[]).filter(t=>this.isEnabled(r(t)))}}}export{u as ManifestError,h as SOURCES,E as createManifest};