@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.
- package/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +1 -4
- package/pdf-layout.js +7 -253
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -442
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/note-plan.js
CHANGED
|
@@ -1,113 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
// "Plan this in a new note" is not one model call. A goal is decomposed into sub-tasks, each
|
|
5
|
-
// sub-task is assigned a ROLE — research (needs facts, options, current information) or write
|
|
6
|
-
// (drafting, structure, synthesis) — and each is then run by whichever member of the team
|
|
7
|
-
// suits it. The note is rebuilt after every step, so the user watches a checklist fill in
|
|
8
|
-
// rather than a spinner.
|
|
9
|
-
//
|
|
10
|
-
// What has to be shared is the SHAPE: the decomposition prompt and how its answer is read,
|
|
11
|
-
// the document the tasks render into, and the authorship ledger that matches that document
|
|
12
|
-
// character for character. What stays in the client is the orchestration — running the calls,
|
|
13
|
-
// choosing the models, and painting the editor.
|
|
14
|
-
//
|
|
15
|
-
// THE LEDGER IS BUILT FROM THE SAME PARTS AS THE BODY, which is the whole reason `planParts`
|
|
16
|
-
// exists rather than a template string. A plan note is written entirely by agents; attributing
|
|
17
|
-
// it to "You" would be a lie the History tab then repeats forever. Building the text and the
|
|
18
|
-
// run-list from one list of `{ author, text }` parts means the ledger cannot drift from the
|
|
19
|
-
// document — it sums to its length by construction, which is the invariant every attribution
|
|
20
|
-
// test asserts.
|
|
1
|
+
import{mergeRuns as h}from"./attribution.js";const m=Object.freeze(["research","write"]),i=Object.freeze({planner:"Planner",research:"Researcher",write:"Writer"}),S='You are a planning orchestrator. Break the goal into 3–6 concrete sub-tasks. For each, pick a role: "research" (needs facts, options, prices, or current info — it will web + history search) or "write" (drafting, structure, synthesis). Return ONLY compact JSON: {"tasks":[{"title":"short title","role":"research|write","prompt":"a focused instruction for this sub-task"}]}',_=600,x=.2;function E(t,e){return`You are drafting ONE section of a plan for the goal "${t}". Write the section titled "${e?.title||""}". Instruction: ${e?.prompt||e?.title||""}. Be concrete and actionable — bullets, - [ ] tasks, or a small table for options. Output ONLY the section's markdown content (no heading, no preamble).`}const O=700,k=.5;function w(t,e="",r=6){let n=[];try{const s=JSON.parse((String(t||"").match(/\{[\s\S]*\}/)||["{}"])[0]);n=(Array.isArray(s.tasks)?s.tasks:[]).slice(0,r).map(a=>p(a))}catch{}return n=n.filter(s=>s.title||s.prompt),!n.length&&String(e).trim()&&(n=[p({title:f(e),role:"write",prompt:e})]),n}const p=t=>({title:String(t?.title||"Task").slice(0,80),role:t?.role==="research"?"research":"write",prompt:String(t?.prompt||t?.title||""),done:!1,working:!1,output:""});function f(t,e=60){return String(t||"").replace(/[#*_`>~[\]]/g,"").replace(/\s+/g," ").trim().slice(0,e)}function u(t,e){const r=Array.isArray(e)?e:[],n=r.filter(o=>o.done).length,s=r.map((o,c)=>`- [${o.done?"x":" "}] ${c+1}. ${o.title}${o.working?" — _working…_":""}`).join(`
|
|
2
|
+
`),a=[{author:i.planner,text:`# ${t}
|
|
21
3
|
|
|
22
|
-
|
|
4
|
+
**Plan** — ${n}/${r.length} sub-tasks done
|
|
23
5
|
|
|
24
|
-
|
|
25
|
-
export const PLAN_ROLES = Object.freeze(['research', 'write']);
|
|
6
|
+
${s}
|
|
26
7
|
|
|
27
|
-
|
|
28
|
-
export const PLAN_AUTHORS = Object.freeze({ planner: 'Planner', research: 'Researcher', write: 'Writer' });
|
|
8
|
+
---
|
|
29
9
|
|
|
30
|
-
|
|
31
|
-
export const PLAN_DECOMPOSE_MAX_TOKENS = 600;
|
|
32
|
-
export const PLAN_DECOMPOSE_TEMPERATURE = 0.2;
|
|
10
|
+
`}];return r.forEach((o,c)=>{const l=o.role==="research"?i.research:i.write;a.push({author:i.planner,text:`## ${c+1}. ${o.title}
|
|
33
11
|
|
|
34
|
-
|
|
35
|
-
export function planSectionSystem(goal, task) {
|
|
36
|
-
return `You are drafting ONE section of a plan for the goal "${goal}". Write the section titled "${task?.title || ''}". Instruction: ${task?.prompt || task?.title || ''}. Be concrete and actionable — bullets, - [ ] tasks, or a small table for options. Output ONLY the section's markdown content (no heading, no preamble).`;
|
|
37
|
-
}
|
|
38
|
-
export const PLAN_SECTION_MAX_TOKENS = 700;
|
|
39
|
-
export const PLAN_SECTION_TEMPERATURE = 0.5;
|
|
12
|
+
`}),a.push({author:o.output?l:i.planner,text:o.output||(o.working?`_⏳ ${l} working…_`:"_pending_")}),a.push({author:i.planner,text:c<r.length-1?`
|
|
40
13
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
44
|
-
* Generous on purpose: this is a model returning JSON through whatever wrapper its provider
|
|
45
|
-
* felt like adding, and a plan that fails because the answer arrived in a fenced block is a
|
|
46
|
-
* plan that failed for no reason. The object is found inside the text, an unknown role
|
|
47
|
-
* becomes `write`, and a completely unreadable answer falls back to ONE task carrying the
|
|
48
|
-
* original goal — which still produces a useful note rather than an error.
|
|
49
|
-
*/
|
|
50
|
-
export function parsePlanTasks(raw, goal = '', max = 6) {
|
|
51
|
-
let tasks = [];
|
|
52
|
-
try {
|
|
53
|
-
const json = JSON.parse((String(raw || '').match(/\{[\s\S]*\}/) || ['{}'])[0]);
|
|
54
|
-
tasks = (Array.isArray(json.tasks) ? json.tasks : []).slice(0, max).map((t) => makeTask(t));
|
|
55
|
-
} catch { /* fall through to the single-task plan */ }
|
|
56
|
-
tasks = tasks.filter((t) => t.title || t.prompt);
|
|
57
|
-
if (!tasks.length && String(goal).trim()) tasks = [makeTask({ title: planTitleFor(goal), role: 'write', prompt: goal })];
|
|
58
|
-
return tasks;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const makeTask = (t) => ({
|
|
62
|
-
title: String(t?.title || 'Task').slice(0, 80),
|
|
63
|
-
role: t?.role === 'research' ? 'research' : 'write',
|
|
64
|
-
prompt: String(t?.prompt || t?.title || ''),
|
|
65
|
-
done: false,
|
|
66
|
-
working: false,
|
|
67
|
-
output: '',
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
/** A goal reduced to something that reads as a title — markdown stripped, one line, capped. */
|
|
71
|
-
export function planTitleFor(goal, max = 60) {
|
|
72
|
-
return String(goal || '').replace(/[#*_`>~[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* The plan note, as `{ author, text }` parts.
|
|
77
|
-
*
|
|
78
|
-
* A checklist at the top that fills in as the work lands — so the note is a progress report
|
|
79
|
-
* at a glance — then one section per sub-task. A task with no output yet says which member is
|
|
80
|
-
* working on it rather than showing an empty heading, because a heading with nothing under it
|
|
81
|
-
* reads as a section that came back empty.
|
|
82
|
-
*/
|
|
83
|
-
export function planParts(goal, tasks) {
|
|
84
|
-
const list = Array.isArray(tasks) ? tasks : [];
|
|
85
|
-
const done = list.filter((t) => t.done).length;
|
|
86
|
-
const checklist = list
|
|
87
|
-
.map((t, i) => `- [${t.done ? 'x' : ' '}] ${i + 1}. ${t.title}${t.working ? ' — _working…_' : ''}`)
|
|
88
|
-
.join('\n');
|
|
89
|
-
const parts = [{
|
|
90
|
-
author: PLAN_AUTHORS.planner,
|
|
91
|
-
text: `# ${goal}\n\n**Plan** — ${done}/${list.length} sub-tasks done\n\n${checklist}\n\n---\n\n`,
|
|
92
|
-
}];
|
|
93
|
-
list.forEach((t, i) => {
|
|
94
|
-
const who = t.role === 'research' ? PLAN_AUTHORS.research : PLAN_AUTHORS.write;
|
|
95
|
-
parts.push({ author: PLAN_AUTHORS.planner, text: `## ${i + 1}. ${t.title}\n\n` });
|
|
96
|
-
parts.push({
|
|
97
|
-
author: t.output ? who : PLAN_AUTHORS.planner,
|
|
98
|
-
text: t.output || (t.working ? `_⏳ ${who} working…_` : '_pending_'),
|
|
99
|
-
});
|
|
100
|
-
parts.push({ author: PLAN_AUTHORS.planner, text: i < list.length - 1 ? '\n\n' : '\n' });
|
|
101
|
-
});
|
|
102
|
-
return parts;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** The plan note's markdown. */
|
|
106
|
-
export function planBody(goal, tasks) {
|
|
107
|
-
return planParts(goal, tasks).map((p) => p.text).join('');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** The authorship run-list for `planBody(goal, tasks)` — equal in length by construction. */
|
|
111
|
-
export function planAttribution(goal, tasks, at = Date.now()) {
|
|
112
|
-
return mergeRuns(planParts(goal, tasks).map((p) => ({ len: p.text.length, author: p.author, at })));
|
|
113
|
-
}
|
|
14
|
+
`:`
|
|
15
|
+
`})}),a}function A(t,e){return u(t,e).map(r=>r.text).join("")}function N(t,e,r=Date.now()){return h(u(t,e).map(n=>({len:n.text.length,author:n.author,at:r})))}export{i as PLAN_AUTHORS,_ as PLAN_DECOMPOSE_MAX_TOKENS,S as PLAN_DECOMPOSE_SYSTEM,x as PLAN_DECOMPOSE_TEMPERATURE,m as PLAN_ROLES,O as PLAN_SECTION_MAX_TOKENS,k as PLAN_SECTION_TEMPERATURE,w as parsePlanTasks,N as planAttribution,A as planBody,u as planParts,E as planSectionSystem,f as planTitleFor};
|
package/note-research.js
CHANGED
|
@@ -1,135 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// A research pane beside a note answers two questions, and both are ranking problems rather
|
|
4
|
-
// than retrieval ones. The retrieval is already shared (`sources-retrieval.js`, `rrf.js`) and
|
|
5
|
-
// the corpus differs per client; what has to be identical is the JUDGEMENT — what words the
|
|
6
|
-
// query is built from, and which results are close enough to show.
|
|
7
|
-
//
|
|
8
|
-
// THE FAILURE THIS EXISTS TO PREVENT IS A PANE FULL OF PLAUSIBLE, UNRELATED THINGS. Any
|
|
9
|
-
// ranker returns its top N for any query, so a note containing "can you check the plan today"
|
|
10
|
-
// retrieves the user's whole history ranked by nothing. Every result looks like a result. The
|
|
11
|
-
// fix is two-sided: build the query from the note's content-bearing words, then require a real
|
|
12
|
-
// overlap before showing anything. **Empty is better than irrelevant** — an empty pane is read
|
|
13
|
-
// as "nothing yet", a full one as "these are related", and only one of those can be wrong.
|
|
14
|
-
//
|
|
15
|
-
// Extracted from the extension's `notes-util.js`, where it was already pure.
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Words that carry no topic, dropped from every query and every relevance test.
|
|
19
|
-
*
|
|
20
|
-
* Beyond ordinary stop-words this drops NOTE-META and AGENT noise — claude, codex, agent,
|
|
21
|
-
* research, question, answer, summarize, https, www — because a note that says "ask Claude to
|
|
22
|
-
* research this" would otherwise be judged to be ABOUT Claude and research, and would match
|
|
23
|
-
* every other note in which the user typed the same sentence.
|
|
24
|
-
*/
|
|
25
|
-
const STOP = new Set(('the a an and or but for to of in on at by with from as is are was were be been being this that these those it its i you your my me we our they them he she his her can could would should will shall may might do does did done get got make made just like about into over under out up down off not no yes plan planning day today check please help note notes write writing claude code codex anthropic agent agents assistant research researcher question questions answer answers answered reply inline summary summarize source sources cite citation https http www com net org html url link links thing things using use used need needs want wants below above here there').split(/\s+/));
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The content-bearing terms of a query — lowercased words of 4+ characters that are not
|
|
29
|
-
* stop-words. Used to judge relevance, so it is about what the text IS, not how it is phrased.
|
|
30
|
-
*/
|
|
31
|
-
export function salientTerms(q) {
|
|
32
|
-
const out = new Set();
|
|
33
|
-
for (const w of String(q || '').toLowerCase().match(/[a-z0-9][a-z0-9'-]{3,}/g) || []) {
|
|
34
|
-
if (!STOP.has(w)) out.add(w);
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* A note's TOPIC terms, most-repeated first — what a good search query is built from.
|
|
41
|
-
*
|
|
42
|
-
* Ranked by FREQUENCY rather than position, because the words at the top of a note are
|
|
43
|
-
* usually its scaffolding ("Notes from the meeting about…") while the words it keeps
|
|
44
|
-
* returning to are its subject. Ties break toward the longer, more specific term.
|
|
45
|
-
*/
|
|
46
|
-
export function topicTerms(text, n = 8) {
|
|
47
|
-
const freq = new Map();
|
|
48
|
-
for (const w of String(text || '').toLowerCase().match(/[a-z][a-z'-]{3,}/g) || []) {
|
|
49
|
-
if (STOP.has(w)) continue;
|
|
50
|
-
freq.set(w, (freq.get(w) || 0) + 1);
|
|
51
|
-
}
|
|
52
|
-
return [...freq.entries()]
|
|
53
|
-
.sort((a, b) => b[1] - a[1] || b[0].length - a[0].length)
|
|
54
|
-
.slice(0, n)
|
|
55
|
-
.map(([w]) => w);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* How related a result is to the query — 0 means "do not show this".
|
|
60
|
-
*
|
|
61
|
-
* A specific word (6+ characters) is worth twice a short one, and a WORKSPACE hit has to earn
|
|
62
|
-
* its place: two matching terms, or one specific one. A single generic word in common is how
|
|
63
|
-
* an unrelated note from March ends up in the pane.
|
|
64
|
-
*
|
|
65
|
-
* `web: true` relaxes that last rule, deliberately. A web result was fetched for a query the
|
|
66
|
-
* user explicitly asked for, and re-gating it on snippet overlap drops valid hits whose
|
|
67
|
-
* snippet happens to paraphrase — which is most of them.
|
|
68
|
-
*/
|
|
69
|
-
export function researchRelevance(card, salient, { web = false } = {}) {
|
|
70
|
-
if (!salient || !salient.size) return 0;
|
|
71
|
-
const hay = `${card?.title || ''} ${card?.snippet || ''}`.toLowerCase();
|
|
72
|
-
let hits = 0;
|
|
73
|
-
let specific = 0;
|
|
74
|
-
let score = 0;
|
|
75
|
-
for (const t of salient) {
|
|
76
|
-
if (!hay.includes(t)) continue;
|
|
77
|
-
hits += 1;
|
|
78
|
-
score += t.length >= 6 ? 2 : 1;
|
|
79
|
-
if (t.length >= 6) specific += 1;
|
|
80
|
-
}
|
|
81
|
-
if (!hits) return 0;
|
|
82
|
-
if (web || hits >= 2 || specific >= 1) return score;
|
|
83
|
-
return 0;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** The web query for a note: its title plus its topic terms, capped at a sane length. */
|
|
87
|
-
export function webQuery(title, terms) {
|
|
88
|
-
return [String(title || ''), [...(terms || [])].slice(0, 8).join(' ')]
|
|
89
|
-
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
|
|
90
|
-
.slice(0, 120);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** One line of a source, flattened — enough to recognise it, not enough to read instead. */
|
|
94
|
-
export function researchSnippet(text = '', max = 160) {
|
|
95
|
-
return String(text).replace(/\s+/g, ' ').trim().slice(0, max);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Rank and gate a set of candidate cards against the query.
|
|
100
|
-
*
|
|
101
|
-
* `{ kind, title, snippet, url, key }` in, the same objects out, ordered by relevance with
|
|
102
|
-
* the irrelevant dropped and `dismissed` keys removed. Web cards keep their retrieval order
|
|
103
|
-
* (the engine already ranked them and the user asked for them); workspace cards are re-ranked
|
|
104
|
-
* on overlap, which is the gate described above.
|
|
105
|
-
*/
|
|
106
|
-
export function rankResearchCards(cards, query, { dismissed = null, web = false } = {}) {
|
|
107
|
-
const salient = salientTerms(query);
|
|
108
|
-
const skip = dismissed instanceof Set ? dismissed : new Set(dismissed || []);
|
|
109
|
-
const kept = (Array.isArray(cards) ? cards : []).filter((c) => c && !skip.has(c.key ?? c.url));
|
|
110
|
-
if (web) return kept;
|
|
111
|
-
return kept
|
|
112
|
-
.map((c) => ({ c, s: researchRelevance(c, salient) }))
|
|
113
|
-
.filter((x) => x.s > 0)
|
|
114
|
-
.sort((a, b) => b.s - a.s)
|
|
115
|
-
.map((x) => x.c);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Merge the two lanes into one shelf.
|
|
120
|
-
*
|
|
121
|
-
* Web first when the user explicitly asked for a web search — that is what they pressed —
|
|
122
|
-
* then the grounded workspace hits, deduped by key across both.
|
|
123
|
-
*/
|
|
124
|
-
export function mergeResearchLanes(webCards, localCards, limit = 12) {
|
|
125
|
-
const out = [];
|
|
126
|
-
const seen = new Set();
|
|
127
|
-
for (const c of [...(webCards || []), ...(localCards || [])]) {
|
|
128
|
-
const k = c?.key ?? c?.url;
|
|
129
|
-
if (!k || seen.has(k)) continue;
|
|
130
|
-
seen.add(k);
|
|
131
|
-
out.push(c);
|
|
132
|
-
if (out.length >= limit) break;
|
|
133
|
-
}
|
|
134
|
-
return out;
|
|
135
|
-
}
|
|
1
|
+
const c=new Set("the a an and or but for to of in on at by with from as is are was were be been being this that these those it its i you your my me we our they them he she his her can could would should will shall may might do does did done get got make made just like about into over under out up down off not no yes plan planning day today check please help note notes write writing claude code codex anthropic agent agents assistant research researcher question questions answer answers answered reply inline summary summarize source sources cite citation https http www com net org html url link links thing things using use used need needs want wants below above here there".split(/\s+/));function l(r){const e=new Set;for(const o of String(r||"").toLowerCase().match(/[a-z0-9][a-z0-9'-]{3,}/g)||[])c.has(o)||e.add(o);return e}function f(r,e=8){const o=new Map;for(const t of String(r||"").toLowerCase().match(/[a-z][a-z'-]{3,}/g)||[])c.has(t)||o.set(t,(o.get(t)||0)+1);return[...o.entries()].sort((t,s)=>s[1]-t[1]||s[0].length-t[0].length).slice(0,e).map(([t])=>t)}function h(r,e,{web:o=!1}={}){if(!e||!e.size)return 0;const t=`${r?.title||""} ${r?.snippet||""}`.toLowerCase();let s=0,a=0,i=0;for(const n of e)t.includes(n)&&(s+=1,i+=n.length>=6?2:1,n.length>=6&&(a+=1));return s&&(o||s>=2||a>=1)?i:0}function p(r,e){return[String(r||""),[...e||[]].slice(0,8).join(" ")].filter(Boolean).join(" ").replace(/\s+/g," ").trim().slice(0,120)}function w(r="",e=160){return String(r).replace(/\s+/g," ").trim().slice(0,e)}function g(r,e,{dismissed:o=null,web:t=!1}={}){const s=l(e),a=o instanceof Set?o:new Set(o||[]),i=(Array.isArray(r)?r:[]).filter(n=>n&&!a.has(n.key??n.url));return t?i:i.map(n=>({c:n,s:h(n,s)})).filter(n=>n.s>0).sort((n,u)=>u.s-n.s).map(n=>n.c)}function m(r,e,o=12){const t=[],s=new Set;for(const a of[...r||[],...e||[]]){const i=a?.key??a?.url;if(!(!i||s.has(i))&&(s.add(i),t.push(a),t.length>=o))break}return t}export{m as mergeResearchLanes,g as rankResearchCards,h as researchRelevance,w as researchSnippet,l as salientTerms,f as topicTerms,p as webQuery};
|
package/observability.js
CHANGED
|
@@ -1,119 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// ChatPanel's data is reachable by more than one agent now: the side panel, and any CLI
|
|
4
|
-
// (Codex, Claude Code, OpenCode…) wired to the gateway's MCP server. Once several agents
|
|
5
|
-
// read your history and skills, you need to SEE that — which agent touched what, and how
|
|
6
|
-
// much sits in each storage tier. That is one question with one answer shape, so it lives
|
|
7
|
-
// here, not re-derived in every client. The extension renders it; the gateway records it;
|
|
8
|
-
// a desktop/mobile app will do both against this same contract.
|
|
9
|
-
//
|
|
10
|
-
// Pure and dependency-free (the @chatpanel/events rule): identical code in browser ESM,
|
|
11
|
-
// the gateway (Node) and a mobile JS runtime. No clock, no storage, no platform APIs —
|
|
12
|
-
// the caller passes `ts`; the caller owns persistence.
|
|
13
|
-
//
|
|
14
|
-
// PRIVACY IS THE POINT of the redactor below. An access log that stored raw tool arguments
|
|
15
|
-
// would quietly become a second copy of every search query — the exact PII we redact
|
|
16
|
-
// everywhere else. So the note attached to each event is built from a per-tool WHITELIST of
|
|
17
|
-
// non-sensitive fields; a search query's TEXT is never recorded, only that a search ran.
|
|
18
|
-
|
|
19
|
-
export const ACCESS_LOG_VERSION = 1;
|
|
20
|
-
|
|
21
|
-
// Default ring size — enough to see a working session's activity without unbounded growth.
|
|
22
|
-
export const ACCESS_LOG_MAX = 500;
|
|
23
|
-
|
|
24
|
-
// Per-tool whitelist: which argument fields are safe to keep in the human note. Anything not
|
|
25
|
-
// listed here is dropped. Content-bearing fields (a search `query`) are deliberately ABSENT —
|
|
26
|
-
// the tool name already says "a search happened"; the words searched are not logged.
|
|
27
|
-
const SAFE_ARGS = {
|
|
28
|
-
// Metadata filters are safe to keep (they are not content) and useful to see in the log:
|
|
29
|
-
// "type=meeting since=7d". The search QUERY is deliberately absent — never recorded.
|
|
30
|
-
search_history: ['type', 'since', 'before', 'limit', 'offset'],
|
|
31
|
-
list_history: ['limit', 'offset'],
|
|
32
|
-
get_record: ['id', 'maxChars', 'offset'], // opaque record id + paging, not content
|
|
33
|
-
find_related: ['id', 'limit'], // graph navigation from an opaque id
|
|
34
|
-
open_skill: ['skill'], // skill names are catalog identifiers, not PII
|
|
35
|
-
read_skill_file: ['skill', 'path'],
|
|
36
|
-
list_skills: ['limit'],
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* A short, SAFE descriptor of a call's arguments for display. Never returns content that
|
|
41
|
-
* could carry PII. Unknown tools get an empty note (the tool name is the only signal).
|
|
42
|
-
*/
|
|
43
|
-
export function redactAccessArgs(tool, args) {
|
|
44
|
-
const allow = SAFE_ARGS[tool];
|
|
45
|
-
if (!allow || !args || typeof args !== 'object') return '';
|
|
46
|
-
const parts = [];
|
|
47
|
-
for (const key of allow) {
|
|
48
|
-
const v = args[key];
|
|
49
|
-
if (v === undefined || v === null || v === '') continue;
|
|
50
|
-
// Cap any string field so a long id/path can't smuggle content or blow up the row.
|
|
51
|
-
const s = typeof v === 'string' ? (v.length > 80 ? `${v.slice(0, 77)}…` : v) : String(v);
|
|
52
|
-
parts.push(`${key}=${s}`);
|
|
53
|
-
}
|
|
54
|
-
return parts.join(' ');
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Normalize one access into the record everything stores and renders. `client` is the calling
|
|
59
|
-
* agent's self-reported name (MCP clientInfo) — untrusted, so it's coerced to a short string.
|
|
60
|
-
*/
|
|
61
|
-
export function makeAccessEvent({ ts, client, tool, ok = true, ms, args, error } = {}) {
|
|
62
|
-
return {
|
|
63
|
-
v: ACCESS_LOG_VERSION,
|
|
64
|
-
ts: Number(ts) || 0,
|
|
65
|
-
client: shortStr(client, 'unknown', 60),
|
|
66
|
-
tool: shortStr(tool, 'unknown', 60),
|
|
67
|
-
ok: !!ok,
|
|
68
|
-
ms: Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : null,
|
|
69
|
-
note: redactAccessArgs(tool, args),
|
|
70
|
-
error: error ? shortStr(error, '', 200) : '',
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function shortStr(v, fallback, max) {
|
|
75
|
-
const s = (v == null ? '' : String(v)).trim() || fallback;
|
|
76
|
-
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* A tiny fixed-capacity ring for access events. Pure and synchronous — the gateway keeps one
|
|
81
|
-
* in memory and snapshots it for the dashboard; the caller decides whether/how to persist.
|
|
82
|
-
*/
|
|
83
|
-
export function createAccessLog(max = ACCESS_LOG_MAX) {
|
|
84
|
-
const cap = Math.max(1, max | 0);
|
|
85
|
-
let buf = [];
|
|
86
|
-
return {
|
|
87
|
-
push(evt) { buf.push(evt); if (buf.length > cap) buf = buf.slice(buf.length - cap); return evt; },
|
|
88
|
-
// Newest first, optionally limited — the order a dashboard wants.
|
|
89
|
-
snapshot(limit) { const out = buf.slice().reverse(); return limit ? out.slice(0, limit) : out; },
|
|
90
|
-
get size() { return buf.length; },
|
|
91
|
-
clear() { buf = []; },
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ── Storage tiers ────────────────────────────────────────────────────────────────────────
|
|
96
|
-
// One descriptor per place data lives: hot (browser), warm (local gateway), cold (cloud,
|
|
97
|
-
// future). The dashboard renders a row per tier; a tier that isn't configured says so.
|
|
98
|
-
|
|
99
|
-
export function makeStorageTier({ tier, label, present = true, records = null, bytes = null, newest = null, note = '' } = {}) {
|
|
100
|
-
return {
|
|
101
|
-
tier: String(tier || ''),
|
|
102
|
-
label: String(label || tier || ''),
|
|
103
|
-
present: !!present,
|
|
104
|
-
records: records == null ? null : Math.max(0, records | 0),
|
|
105
|
-
bytes: bytes == null ? null : Math.max(0, Number(bytes) || 0),
|
|
106
|
-
newest: newest == null ? null : Number(newest) || 0,
|
|
107
|
-
note: String(note || ''),
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Human-friendly byte size. Binary units, one decimal above KB. */
|
|
112
|
-
export function formatBytes(n) {
|
|
113
|
-
const b = Number(n);
|
|
114
|
-
if (!Number.isFinite(b) || b <= 0) return '0 B';
|
|
115
|
-
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
116
|
-
let i = 0, v = b;
|
|
117
|
-
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
118
|
-
return `${i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
|
119
|
-
}
|
|
1
|
+
const f=1,a=500,u={search_history:["type","since","before","limit","offset"],list_history:["limit","offset"],get_record:["id","maxChars","offset"],find_related:["id","limit"],open_skill:["skill"],read_skill_file:["skill","path"],list_skills:["limit"]};function c(l,n){const t=u[l];if(!t||!n||typeof n!="object")return"";const e=[];for(const r of t){const i=n[r];if(i==null||i==="")continue;const s=typeof i=="string"?i.length>80?`${i.slice(0,77)}…`:i:String(i);e.push(`${r}=${s}`)}return e.join(" ")}function h({ts:l,client:n,tool:t,ok:e=!0,ms:r,args:i,error:s}={}){return{v:1,ts:Number(l)||0,client:o(n,"unknown",60),tool:o(t,"unknown",60),ok:!!e,ms:Number.isFinite(r)?Math.max(0,Math.round(r)):null,note:c(t,i),error:s?o(s,"",200):""}}function o(l,n,t){const e=(l==null?"":String(l)).trim()||n;return e.length>t?`${e.slice(0,t-1)}…`:e}function p(l=500){const n=Math.max(1,l|0);let t=[];return{push(e){return t.push(e),t.length>n&&(t=t.slice(t.length-n)),e},snapshot(e){const r=t.slice().reverse();return e?r.slice(0,e):r},get size(){return t.length},clear(){t=[]}}}function S({tier:l,label:n,present:t=!0,records:e=null,bytes:r=null,newest:i=null,note:s=""}={}){return{tier:String(l||""),label:String(n||l||""),present:!!t,records:e==null?null:Math.max(0,e|0),bytes:r==null?null:Math.max(0,Number(r)||0),newest:i==null?null:Number(i)||0,note:String(s||"")}}function m(l){const n=Number(l);if(!Number.isFinite(n)||n<=0)return"0 B";const t=["B","KB","MB","GB","TB"];let e=0,r=n;for(;r>=1024&&e<t.length-1;)r/=1024,e++;return`${e===0?Math.round(r):r.toFixed(1)} ${t[e]}`}export{a as ACCESS_LOG_MAX,f as ACCESS_LOG_VERSION,p as createAccessLog,m as formatBytes,h as makeAccessEvent,S as makeStorageTier,c as redactAccessArgs};
|
package/omni.js
CHANGED
|
@@ -1,125 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Every ChatPanel surface is growing the same input: a single field that searches the
|
|
4
|
-
// corpus, asks the model, jumps to a subject, filters by tag, or runs a command. The
|
|
5
|
-
// extension has an omni modal, the desktop has an ambient composer, mobile will have a
|
|
6
|
-
// search sheet. If each decides on its own what `#atlas` means, muscle memory stops
|
|
7
|
-
// transferring between them — which is the whole value of a command bar.
|
|
8
|
-
//
|
|
9
|
-
// So the GRAMMAR lives here and the surfaces only decide how to paint it. Pure string in,
|
|
10
|
-
// structured intent out. No storage, no search, no model.
|
|
11
|
-
//
|
|
12
|
-
// WHY PREFIXES RATHER THAN A MODE PICKER: a picker costs a click before every query and has
|
|
13
|
-
// to be reset afterwards. A prefix is typed in the same keystroke as the query and is
|
|
14
|
-
// self-evident on screen. The set is deliberately tiny — five modes, each one character —
|
|
15
|
-
// because a grammar nobody can recall is a grammar nobody uses.
|
|
16
|
-
//
|
|
17
|
-
// AMBIGUITY RESOLVES TOWARD SEARCH. Anything unrecognised is a search, never an error: a bar
|
|
18
|
-
// that refuses input is worse than one that searches for a literal "?" .
|
|
19
|
-
|
|
20
|
-
export const OMNI_MODES = Object.freeze(['search', 'ask', 'subject', 'tag', 'command']);
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The grammar. `prefix` is what the user types; `hint` is what a surface shows in the
|
|
24
|
-
* mode strip. Order is display order.
|
|
25
|
-
*/
|
|
26
|
-
export const OMNI_GRAMMAR = Object.freeze([
|
|
27
|
-
Object.freeze({ mode: 'search', prefix: '', hint: 'search' }),
|
|
28
|
-
Object.freeze({ mode: 'ask', prefix: '?', hint: 'ask' }),
|
|
29
|
-
Object.freeze({ mode: 'subject', prefix: '@', hint: 'subject' }),
|
|
30
|
-
Object.freeze({ mode: 'tag', prefix: '#', hint: 'tag' }),
|
|
31
|
-
Object.freeze({ mode: 'command', prefix: '>', hint: 'command' }),
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
const BY_PREFIX = new Map(OMNI_GRAMMAR.filter((g) => g.prefix).map((g) => [g.prefix, g.mode]));
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Filters a surface may apply inside a search. Kept here so `type:note since:7d` means the
|
|
38
|
-
* same thing in the extension's omni and the desktop's bar — and the same thing the
|
|
39
|
-
* gateway's `search_history` already accepts.
|
|
40
|
-
*/
|
|
41
|
-
const FILTER_KEYS = new Set(['type', 'since', 'before', 'in', 'limit']);
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Parse one line.
|
|
45
|
-
*
|
|
46
|
-
* Returns `{ mode, query, prefix, filters, raw }`. `query` has the prefix and any recognised
|
|
47
|
-
* `key:value` filters removed, so it is ready to hand to a search or a model verbatim.
|
|
48
|
-
*/
|
|
49
|
-
export function parseOmni(input) {
|
|
50
|
-
const raw = String(input ?? '');
|
|
51
|
-
const trimmed = raw.trimStart();
|
|
52
|
-
const first = trimmed.slice(0, 1);
|
|
53
|
-
const mode = BY_PREFIX.get(first) || 'search';
|
|
54
|
-
const prefix = mode === 'search' ? '' : first;
|
|
55
|
-
|
|
56
|
-
// Only strip the prefix when it IS one — a search for "#" alone should still search.
|
|
57
|
-
let rest = prefix ? trimmed.slice(1) : trimmed;
|
|
58
|
-
rest = rest.replace(/^\s+/, '');
|
|
59
|
-
|
|
60
|
-
const { query, filters } = extractFilters(rest);
|
|
61
|
-
return { mode, prefix, query, filters, raw };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Pull `key:value` pairs out of a query.
|
|
66
|
-
*
|
|
67
|
-
* A value may be quoted (`in:"cutover review"`). Unknown keys are LEFT IN the query rather
|
|
68
|
-
* than dropped, because `http://example.com` and `note:` typed by mistake are both far more
|
|
69
|
-
* likely than a user inventing a filter we forgot to implement.
|
|
70
|
-
*/
|
|
71
|
-
export function extractFilters(text) {
|
|
72
|
-
const filters = {};
|
|
73
|
-
const kept = [];
|
|
74
|
-
// The `key:` prefix has to be part of the quoted alternative. Without it `\S+` matches
|
|
75
|
-
// `in:"cutover` first and the value loses everything after its first space.
|
|
76
|
-
const tokens = String(text || '').match(/(?:[A-Za-z]+:)?"[^"]*"|\S+/g) || [];
|
|
77
|
-
|
|
78
|
-
for (const tok of tokens) {
|
|
79
|
-
const m = /^([a-z]+):(.*)$/i.exec(tok);
|
|
80
|
-
if (m && FILTER_KEYS.has(m[1].toLowerCase())) {
|
|
81
|
-
const key = m[1].toLowerCase();
|
|
82
|
-
const value = m[2].replace(/^"|"$/g, '');
|
|
83
|
-
if (value) filters[key] = value;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
kept.push(tok.replace(/^"|"$/g, ''));
|
|
87
|
-
}
|
|
88
|
-
return { query: kept.join(' ').trim(), filters };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* A relative duration (`7d`, `24h`, `30m`) or a date, resolved against an injected `now`.
|
|
93
|
-
*
|
|
94
|
-
* `now` is a parameter for the reason it is one in `loop.js`: a function that reads the
|
|
95
|
-
* clock cannot be tested, and "since 7d" must mean the same span in every client.
|
|
96
|
-
*/
|
|
97
|
-
export function resolveSince(value, now = Date.now()) {
|
|
98
|
-
const s = String(value || '').trim().toLowerCase();
|
|
99
|
-
if (!s) return 0;
|
|
100
|
-
if (s === 'today') { const d = new Date(now); d.setHours(0, 0, 0, 0); return d.getTime(); }
|
|
101
|
-
if (s === 'yesterday') { const d = new Date(now); d.setHours(0, 0, 0, 0); return d.getTime() - 86_400_000; }
|
|
102
|
-
const rel = /^(\d+)\s*([mhdw])$/.exec(s);
|
|
103
|
-
if (rel) {
|
|
104
|
-
const n = Number(rel[1]);
|
|
105
|
-
const unit = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[rel[2]];
|
|
106
|
-
return now - n * unit;
|
|
107
|
-
}
|
|
108
|
-
const t = Date.parse(s);
|
|
109
|
-
return Number.isFinite(t) ? t : 0;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Does this line ask for a model turn? Surfaces use it to decide whether to warm a target. */
|
|
113
|
-
export function wantsModel(parsed) {
|
|
114
|
-
return !!parsed && parsed.mode === 'ask';
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Is the line worth acting on yet? Guards the "search on every keystroke" path so a bare
|
|
119
|
-
* prefix does not run an empty query against the whole corpus.
|
|
120
|
-
*/
|
|
121
|
-
export function isActionable(parsed, { minChars = 2 } = {}) {
|
|
122
|
-
if (!parsed) return false;
|
|
123
|
-
if (parsed.mode === 'command') return parsed.query.length >= 1;
|
|
124
|
-
return parsed.query.length >= minChars;
|
|
125
|
-
}
|
|
1
|
+
const l=Object.freeze(["search","ask","subject","tag","command"]),f=Object.freeze([Object.freeze({mode:"search",prefix:"",hint:"search"}),Object.freeze({mode:"ask",prefix:"?",hint:"ask"}),Object.freeze({mode:"subject",prefix:"@",hint:"subject"}),Object.freeze({mode:"tag",prefix:"#",hint:"tag"}),Object.freeze({mode:"command",prefix:">",hint:"command"})]),m=new Map(f.filter(e=>e.prefix).map(e=>[e.prefix,e.mode])),u=new Set(["type","since","before","in","limit"]);function d(e){const n=String(e??""),r=n.trimStart(),o=r.slice(0,1),c=m.get(o)||"search",t=c==="search"?"":o;let s=t?r.slice(1):r;s=s.replace(/^\s+/,"");const{query:i,filters:a}=p(s);return{mode:c,prefix:t,query:i,filters:a,raw:n}}function p(e){const n={},r=[],o=String(e||"").match(/(?:[A-Za-z]+:)?"[^"]*"|\S+/g)||[];for(const c of o){const t=/^([a-z]+):(.*)$/i.exec(c);if(t&&u.has(t[1].toLowerCase())){const s=t[1].toLowerCase(),i=t[2].replace(/^"|"$/g,"");i&&(n[s]=i);continue}r.push(c.replace(/^"|"$/g,""))}return{query:r.join(" ").trim(),filters:n}}function x(e,n=Date.now()){const r=String(e||"").trim().toLowerCase();if(!r)return 0;if(r==="today"){const t=new Date(n);return t.setHours(0,0,0,0),t.getTime()}if(r==="yesterday"){const t=new Date(n);return t.setHours(0,0,0,0),t.getTime()-864e5}const o=/^(\d+)\s*([mhdw])$/.exec(r);if(o){const t=Number(o[1]),s={m:6e4,h:36e5,d:864e5,w:6048e5}[o[2]];return n-t*s}const c=Date.parse(r);return Number.isFinite(c)?c:0}function h(e){return!!e&&e.mode==="ask"}function b(e,{minChars:n=2}={}){return e?e.mode==="command"?e.query.length>=1:e.query.length>=n:!1}export{f as OMNI_GRAMMAR,l as OMNI_MODES,p as extractFilters,b as isActionable,d as parseOmni,x as resolveSince,h as wantsModel};
|
package/order.js
CHANGED
|
@@ -1,78 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Replay orders events by topological sort over `causes`, breaking ties by
|
|
4
|
-
// (host, seq) with hosts in lexicographic id order. Wall time is never consulted.
|
|
5
|
-
//
|
|
6
|
-
// This is what makes replay deterministic when two hosts append concurrently. A
|
|
7
|
-
// timestamp is not an order: clocks skew, and two hosts can stamp the same millisecond.
|
|
8
|
-
// `causes` gives a partial order; the tie-break makes it total; neither depends on a
|
|
9
|
-
// clock, so the same set of events linearizes identically on every host, forever.
|
|
10
|
-
//
|
|
11
|
-
// Dangling causes (an id we do not hold, e.g. a partial export) are IGNORED rather than
|
|
12
|
-
// fatal, so a sanitized or truncated trace still replays. A cycle is fatal, because it
|
|
13
|
-
// means the log is corrupt.
|
|
14
|
-
|
|
15
|
-
import { EventError } from './event.js';
|
|
16
|
-
|
|
17
|
-
/** (host, seq) with hosts lexicographic — the deterministic tie-break. */
|
|
18
|
-
export function compareEvents(a, b) {
|
|
19
|
-
if (a.host !== b.host) return a.host < b.host ? -1 : 1;
|
|
20
|
-
if (a.seq !== b.seq) return a.seq - b.seq;
|
|
21
|
-
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; // total, for identical (host,seq)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Deterministic topological linearization. Pure; input array is not mutated.
|
|
26
|
-
* @throws EventError('CYCLE') if `causes` contains a cycle.
|
|
27
|
-
*/
|
|
28
|
-
export function linearize(events) {
|
|
29
|
-
const byId = new Map();
|
|
30
|
-
for (const e of events) byId.set(e.id, e);
|
|
31
|
-
|
|
32
|
-
const indegree = new Map();
|
|
33
|
-
const dependents = new Map();
|
|
34
|
-
for (const e of events) {
|
|
35
|
-
const deps = e.causes.filter((c) => byId.has(c)); // dangling causes ignored
|
|
36
|
-
indegree.set(e.id, deps.length);
|
|
37
|
-
for (const d of deps) {
|
|
38
|
-
if (!dependents.has(d)) dependents.set(d, []);
|
|
39
|
-
dependents.get(d).push(e.id);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Kahn's algorithm with a ready-set kept in (host, seq) order, so the output is a
|
|
44
|
-
// function of the event set alone and not of the input array's order.
|
|
45
|
-
const ready = events.filter((e) => indegree.get(e.id) === 0).sort(compareEvents);
|
|
46
|
-
const out = [];
|
|
47
|
-
while (ready.length > 0) {
|
|
48
|
-
const next = ready.shift();
|
|
49
|
-
out.push(next);
|
|
50
|
-
const kids = dependents.get(next.id);
|
|
51
|
-
if (!kids) continue;
|
|
52
|
-
let unlocked = false;
|
|
53
|
-
for (const kid of kids) {
|
|
54
|
-
const left = indegree.get(kid) - 1;
|
|
55
|
-
indegree.set(kid, left);
|
|
56
|
-
if (left === 0) { ready.push(byId.get(kid)); unlocked = true; }
|
|
57
|
-
}
|
|
58
|
-
if (unlocked) ready.sort(compareEvents);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (out.length !== events.length) {
|
|
62
|
-
const stuck = events.filter((e) => !out.includes(e)).map((e) => e.id);
|
|
63
|
-
throw new EventError('CYCLE', 'causes contains a cycle', stuck);
|
|
64
|
-
}
|
|
65
|
-
return out;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** True when `causes` never points forward within one host's own sequence. */
|
|
69
|
-
export function causesAreWellFormed(events) {
|
|
70
|
-
const byId = new Map(events.map((e) => [e.id, e]));
|
|
71
|
-
for (const e of events) {
|
|
72
|
-
for (const c of e.causes) {
|
|
73
|
-
const cause = byId.get(c);
|
|
74
|
-
if (cause && cause.host === e.host && cause.seq >= e.seq) return false;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
1
|
+
import{EventError as l}from"./event.js";function h(t,s){return t.host!==s.host?t.host<s.host?-1:1:t.seq!==s.seq?t.seq-s.seq:t.id<s.id?-1:t.id>s.id?1:0}function a(t){const s=new Map;for(const e of t)s.set(e.id,e);const o=new Map,c=new Map;for(const e of t){const n=e.causes.filter(i=>s.has(i));o.set(e.id,n.length);for(const i of n)c.has(i)||c.set(i,[]),c.get(i).push(e.id)}const r=t.filter(e=>o.get(e.id)===0).sort(h),f=[];for(;r.length>0;){const e=r.shift();f.push(e);const n=c.get(e.id);if(!n)continue;let i=!1;for(const d of n){const u=o.get(d)-1;o.set(d,u),u===0&&(r.push(s.get(d)),i=!0)}i&&r.sort(h)}if(f.length!==t.length){const e=t.filter(n=>!f.includes(n)).map(n=>n.id);throw new l("CYCLE","causes contains a cycle",e)}return f}function g(t){const s=new Map(t.map(o=>[o.id,o]));for(const o of t)for(const c of o.causes){const r=s.get(c);if(r&&r.host===o.host&&r.seq>=o.seq)return!1}return!0}export{g as causesAreWellFormed,h as compareEvents,a as linearize};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.103.1",
|
|
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",
|
|
@@ -284,9 +284,6 @@
|
|
|
284
284
|
"web-search.js",
|
|
285
285
|
"widget.js"
|
|
286
286
|
],
|
|
287
|
-
"scripts": {
|
|
288
|
-
"test": "node --test tests/*.test.js"
|
|
289
|
-
},
|
|
290
287
|
"repository": {
|
|
291
288
|
"type": "git",
|
|
292
289
|
"url": "git+https://github.com/chatpanel/chatpanel-events.git"
|