@chatpanel/events 0.101.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 -348
  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/recipe-tool.js CHANGED
@@ -1,161 +1,2 @@
1
- // The `recipe` tool — a workflow the model did once, kept as data, and run by name.
2
- //
3
- // The engine is @chatpanel/events recipe.js: a recipe is `call` / `parallel` / `batch` /
4
- // `pipeline` with `{ "$param": "x" }` slots, expanded, dry-run and run with no model in
5
- // the loop. What is here is the CONVERSATIONAL half the spine's amendment A2 asked for —
6
- // authored in chat, approved before it exists, declarative, never agent-written code:
7
- //
8
- // save the model proposes a recipe from what it just did ("you fetched two issues
9
- // and compared them — keep that as `triage_pair`?"). The dry run is what the
10
- // PERSON sees on the card: every step, every slot, every warning. Approval
11
- // stores it; nothing runs.
12
- // dry_run the same report, on demand, for a saved recipe with real parameters.
13
- // run expand with the given parameters and execute through the turn's OWN toolset —
14
- // so the destructive gate, redaction, the loop guard and the shield all apply
15
- // to every step exactly as if the model had called it. A recipe composes calls;
16
- // it is never a way around them.
17
- //
18
- // One registered tool, not three: a spec per verb is per-turn token cost on every armed
19
- // turn, and the description already lists the saved recipes by name and parameters, which
20
- // is the whole catalogue. Bound LATE to the toolset it lives in (`bind`), because the
21
- // toolset that runs its steps is the one it is a member of.
22
- //
23
- // Shared: the extension and the desktop arm the same tool over the same `recipes` prefs
24
- // section, so a recipe approved in one client runs in the other. The approval card and
25
- // the store are the host's (injected); everything the model sees is here.
26
-
27
- import { validateRecipe, expandRecipe, dryRunRecipe, runPlan, recipeParams, RECIPE_MODES } from './recipe.js';
28
-
29
- export const RECIPE_TOOL_NAME = 'recipe';
30
-
31
- const RECIPE_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
32
-
33
- function catalogue(recipes) {
34
- const list = (recipes || []).filter((r) => r && r.enabled !== false && r.name);
35
- if (!list.length) return 'No recipes saved yet.';
36
- return `Saved recipes: ${list.map((r) => {
37
- const ps = recipeParams(r).map((p) => (p.required ? p.name : `${p.name}?`));
38
- return `${r.name}(${ps.join(', ')})${r.description ? ` — ${r.description}` : ''}`;
39
- }).join('; ')}.`;
40
- }
41
-
42
- /**
43
- * One spec. The shape a model needs to `save` is spelled out once, compactly; the
44
- * engine's validator names anything it got wrong.
45
- */
46
- export function recipeToolSpec(recipes) {
47
- return {
48
- name: RECIPE_TOOL_NAME,
49
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
50
- description:
51
- `Saved, repeatable tool workflows — run by name with no re-planning. ${catalogue(recipes)} `
52
- + 'Actions: {"action":"run","name":"<recipe>","params":{…}} runs one; '
53
- + '{"action":"dry_run","name":"<recipe>","params":{…}} shows what it would do without running; '
54
- + '{"action":"save","recipe":{…}} proposes a NEW one after you have done a multi-step task the '
55
- + 'user may repeat — the user approves it on a card. A recipe: {"name":"open_bug","description":"…",'
56
- + '"mode":"call","tool":"<tool name, e.g. mcp_github__create_issue>","arguments":{"title":{"$param":"title"},"labels":["bug"]}}. '
57
- + `Modes: ${RECIPE_MODES.join(' | ')} — parallel takes "calls":[{tool,arguments}], batch takes "tool"+"items":[{arguments}], `
58
- + 'pipeline takes "steps":[{tool,arguments,inputMapping:{arg:"$json.path"|"$text"}}]. '
59
- + 'Use {"$param":"x"} for anything the user will supply each time; a "default" makes it optional.',
60
- parameters: {
61
- type: 'object',
62
- properties: {
63
- action: { type: 'string', enum: ['run', 'dry_run', 'save'] },
64
- name: { type: 'string', description: 'Recipe name, for run / dry_run.' },
65
- params: { type: 'object', description: 'Parameter values, for run / dry_run.', additionalProperties: true },
66
- recipe: { type: 'object', description: 'The recipe to save, for save.', additionalProperties: true },
67
- },
68
- required: ['action'],
69
- },
70
- };
71
- }
72
-
73
- /** The card a person approves: what it does, step by step, and what the dry run flagged. */
74
- export function describeRecipeForApproval(recipe, report) {
75
- const lines = [`${recipe.name}${recipe.description ? ` — ${recipe.description}` : ''}`, `Mode: ${recipe.mode}`];
76
- const ps = recipeParams(recipe);
77
- if (ps.length) lines.push(`Parameters: ${ps.map((p) => (p.required ? p.name : `${p.name} (optional)`)).join(', ')}`);
78
- const calls = report?.calls || [];
79
- calls.forEach((c, i) => {
80
- const args = JSON.stringify(c.arguments);
81
- lines.push(`${calls.length > 1 ? `${i + 1}. ` : ''}${c.tool}${args && args !== '{}' ? ` ${args.length > 140 ? `${args.slice(0, 137)}…` : args}` : ''}${c.mappedLater?.length ? ` (+ ${c.mappedLater.join(', ')} from the previous step)` : ''}`);
82
- });
83
- for (const w of report?.warnings || []) lines.push(`⚠ ${w.message}`);
84
- lines.push('Saved recipes run with no further planning; destructive steps still ask each time.');
85
- return lines.join('\n');
86
- }
87
-
88
- const json = (v) => JSON.stringify(v);
89
-
90
- /**
91
- * @param recipes the saved list (settings.recipes)
92
- * @param confirmSave async (detail, recipe) => 'allow' | 'deny' — the surface's card. Absent on a
93
- * surface with no window: `save` is then refused, `run` still works.
94
- * @param saveRecipe async (recipe) => void — persist an approved one
95
- */
96
- export function recipeToolProvider({ recipes = [], confirmSave = null, saveRecipe = null } = {}) {
97
- let bound = null; // { execute, specs, traits, hiddenVia }
98
- const byName = new Map((recipes || []).filter((r) => r?.name && r.enabled !== false).map((r) => [r.name, r]));
99
-
100
- // Steps name REAL tools; a tool that lives behind a dispatcher (`mcp_gh__get_issue`
101
- // behind `mcp`) is reached through it, so every guard that keys on the action fires.
102
- const execute = (tool, args, meta) => {
103
- if (!bound) return json({ error: 'The recipe tool is not bound to a toolset yet.' });
104
- const via = bound.hiddenVia?.get(tool);
105
- return via ? bound.execute(via, { action: tool, args }, meta) : bound.execute(tool, args, meta);
106
- };
107
- const specsForDryRun = () => (bound ? [...(bound.specs || []), ...(bound.reach || [])].filter((s) => s?.name !== RECIPE_TOOL_NAME) : null);
108
- const traitsOf = (tool) => bound?.traits?.get(tool) || null;
109
-
110
- return {
111
- id: 'recipe',
112
- specs: [recipeToolSpec(recipes)],
113
- system: byName.size ? 'Saved recipes exist (see the `recipe` tool). When the user asks for one by name or describes what one does, run it rather than re-planning the steps.' : '',
114
- bind(toolset) {
115
- bound = { execute: toolset.execute.bind(toolset), specs: toolset.specs, reach: toolset.reach, traits: toolset.traits, hiddenVia: toolset.hiddenVia };
116
- },
117
- async execute(name, input) {
118
- if (name !== RECIPE_TOOL_NAME) return json({ error: `Unknown tool: ${name}` });
119
- const action = String(input?.action || '');
120
-
121
- if (action === 'save') {
122
- const recipe = input?.recipe;
123
- const v = validateRecipe(recipe);
124
- if (!v.ok) return json({ error: 'The recipe is not valid.', problems: v.errors });
125
- if (!RECIPE_NAME_RE.test(recipe.name)) return json({ error: 'name must be a short identifier: letters, digits, _ or -.' });
126
- if (byName.has(recipe.name)) return json({ error: `A recipe named "${recipe.name}" already exists. Pick another name.` });
127
- if (!confirmSave || !saveRecipe) return json({ error: 'Saving a recipe needs the user\'s approval, which this surface cannot ask for. Describe the recipe to the user and suggest saving it from the side panel.' });
128
- const report = dryRunRecipe(recipe, {}, { specs: specsForDryRun(), traitsOf: bound ? (t) => traitsOf(t) || undefined : null });
129
- // Missing parameters are the POINT of a template; only structural problems block.
130
- const blocking = (report.warnings || []).filter((w) => w.code === 'unknown_tool');
131
- if (blocking.length) return json({ error: 'The recipe names tools that are not available in this conversation.', problems: blocking.map((w) => w.message) });
132
- const detail = describeRecipeForApproval(recipe, report);
133
- const decision = await confirmSave(detail, recipe);
134
- if (decision !== 'allow') return json({ error: `The user did not save "${recipe.name}". Do not propose it again this turn.`, declined: true });
135
- const stored = { ...recipe, enabled: true, createdAt: Date.now() };
136
- await saveRecipe(stored);
137
- byName.set(stored.name, stored);
138
- return json({ saved: stored.name, params: recipeParams(stored).map((p) => p.name), hint: `Run it later with {"action":"run","name":"${stored.name}","params":{…}} or by typing /${stored.name}.` });
139
- }
140
-
141
- const recipe = byName.get(String(input?.name || ''));
142
- if (!recipe) return json({ error: `No recipe named "${input?.name}".`, available: [...byName.keys()] });
143
- const params = input?.params && typeof input.params === 'object' ? input.params : {};
144
-
145
- if (action === 'dry_run') {
146
- const report = dryRunRecipe(recipe, params, { specs: specsForDryRun(), traitsOf: bound ? (t) => traitsOf(t) || undefined : null });
147
- return json({ name: recipe.name, ok: report.ok, missing: report.missing, calls: report.calls.map((c) => ({ tool: c.tool, arguments: c.arguments, known: c.known, destructive: c.traits?.destructive === true, mappedLater: c.mappedLater })), warnings: report.warnings });
148
- }
149
-
150
- if (action === 'run') {
151
- const ex = expandRecipe(recipe, params);
152
- if (!ex.plan) return json({ error: 'The saved recipe is not valid.', problems: ex.errors });
153
- if (!ex.ok) return json({ error: `Missing parameter(s): ${ex.missing.join(', ')}.`, params: recipeParams(recipe) });
154
- const result = await runPlan(ex.plan, { execute, traitsOf: (t) => traitsOf(t) || undefined });
155
- return json({ name: recipe.name, ...result });
156
- }
157
-
158
- return json({ error: `Unknown action "${action}". Use run, dry_run or save.` });
159
- },
160
- };
161
- }
1
+ import{validateRecipe as x,expandRecipe as j,dryRunRecipe as k,runPlan as E,recipeParams as v,RECIPE_MODES as P}from"./recipe.js";const y="recipe",R=/^[a-z][a-z0-9_-]{0,63}$/i;function O(s){const m=(s||[]).filter(n=>n&&n.enabled!==!1&&n.name);return m.length?`Saved recipes: ${m.map(n=>{const t=v(n).map(i=>i.required?i.name:`${i.name}?`);return`${n.name}(${t.join(", ")})${n.description?` — ${n.description}`:""}`}).join("; ")}.`:"No recipes saved yet."}function S(s){return{name:y,annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1},description:`Saved, repeatable tool workflows run by name with no re-planning. ${O(s)} Actions: {"action":"run","name":"<recipe>","params":{…}} runs one; {"action":"dry_run","name":"<recipe>","params":{…}} shows what it would do without running; {"action":"save","recipe":{…}} proposes a NEW one after you have done a multi-step task the user may repeat — the user approves it on a card. A recipe: {"name":"open_bug","description":"…","mode":"call","tool":"<tool name, e.g. mcp_github__create_issue>","arguments":{"title":{"$param":"title"},"labels":["bug"]}}. Modes: ${P.join(" | ")} — parallel takes "calls":[{tool,arguments}], batch takes "tool"+"items":[{arguments}], pipeline takes "steps":[{tool,arguments,inputMapping:{arg:"$json.path"|"$text"}}]. Use {"$param":"x"} for anything the user will supply each time; a "default" makes it optional.`,parameters:{type:"object",properties:{action:{type:"string",enum:["run","dry_run","save"]},name:{type:"string",description:"Recipe name, for run / dry_run."},params:{type:"object",description:"Parameter values, for run / dry_run.",additionalProperties:!0},recipe:{type:"object",description:"The recipe to save, for save.",additionalProperties:!0}},required:["action"]}}}function T(s,m){const n=[`${s.name}${s.description?` — ${s.description}`:""}`,`Mode: ${s.mode}`],t=v(s);t.length&&n.push(`Parameters: ${t.map(o=>o.required?o.name:`${o.name} (optional)`).join(", ")}`);const i=m?.calls||[];i.forEach((o,g)=>{const u=JSON.stringify(o.arguments);n.push(`${i.length>1?`${g+1}. `:""}${o.tool}${u&&u!=="{}"?` ${u.length>140?`${u.slice(0,137)}…`:u}`:""}${o.mappedLater?.length?` (+ ${o.mappedLater.join(", ")} from the previous step)`:""}`)});for(const o of m?.warnings||[])n.push(`⚠ ${o.message}`);return n.push("Saved recipes run with no further planning; destructive steps still ask each time."),n.join(`
2
+ `)}const a=s=>JSON.stringify(s);function A({recipes:s=[],confirmSave:m=null,saveRecipe:n=null}={}){let t=null;const i=new Map((s||[]).filter(e=>e?.name&&e.enabled!==!1).map(e=>[e.name,e])),o=(e,c,d)=>{if(!t)return a({error:"The recipe tool is not bound to a toolset yet."});const l=t.hiddenVia?.get(e);return l?t.execute(l,{action:e,args:c},d):t.execute(e,c,d)},g=()=>t?[...t.specs||[],...t.reach||[]].filter(e=>e?.name!==y):null,u=e=>t?.traits?.get(e)||null;return{id:"recipe",specs:[S(s)],system:i.size?"Saved recipes exist (see the `recipe` tool). When the user asks for one by name or describes what one does, run it rather than re-planning the steps.":"",bind(e){t={execute:e.execute.bind(e),specs:e.specs,reach:e.reach,traits:e.traits,hiddenVia:e.hiddenVia}},async execute(e,c){if(e!==y)return a({error:`Unknown tool: ${e}`});const d=String(c?.action||"");if(d==="save"){const r=c?.recipe,p=x(r);if(!p.ok)return a({error:"The recipe is not valid.",problems:p.errors});if(!R.test(r.name))return a({error:"name must be a short identifier: letters, digits, _ or -."});if(i.has(r.name))return a({error:`A recipe named "${r.name}" already exists. Pick another name.`});if(!m||!n)return a({error:"Saving a recipe needs the user's approval, which this surface cannot ask for. Describe the recipe to the user and suggest saving it from the side panel."});const $=k(r,{},{specs:g(),traitsOf:t?f=>u(f)||void 0:null}),w=($.warnings||[]).filter(f=>f.code==="unknown_tool");if(w.length)return a({error:"The recipe names tools that are not available in this conversation.",problems:w.map(f=>f.message)});const _=T(r,$);if(await m(_,r)!=="allow")return a({error:`The user did not save "${r.name}". Do not propose it again this turn.`,declined:!0});const h={...r,enabled:!0,createdAt:Date.now()};return await n(h),i.set(h.name,h),a({saved:h.name,params:v(h).map(f=>f.name),hint:`Run it later with {"action":"run","name":"${h.name}","params":{…}} or by typing /${h.name}.`})}const l=i.get(String(c?.name||""));if(!l)return a({error:`No recipe named "${c?.name}".`,available:[...i.keys()]});const b=c?.params&&typeof c.params=="object"?c.params:{};if(d==="dry_run"){const r=k(l,b,{specs:g(),traitsOf:t?p=>u(p)||void 0:null});return a({name:l.name,ok:r.ok,missing:r.missing,calls:r.calls.map(p=>({tool:p.tool,arguments:p.arguments,known:p.known,destructive:p.traits?.destructive===!0,mappedLater:p.mappedLater})),warnings:r.warnings})}if(d==="run"){const r=j(l,b);if(!r.plan)return a({error:"The saved recipe is not valid.",problems:r.errors});if(!r.ok)return a({error:`Missing parameter(s): ${r.missing.join(", ")}.`,params:v(l)});const p=await E(r.plan,{execute:o,traitsOf:$=>u($)||void 0});return a({name:l.name,...p})}return a({error:`Unknown action "${d}". Use run, dry_run or save.`})}}}export{y as RECIPE_TOOL_NAME,T as describeRecipeForApproval,A as recipeToolProvider,S as recipeToolSpec};
package/recipe.js CHANGED
@@ -1,264 +1 @@
1
- // Recipes a multi-step tool workflow written down once and run by name, with no model
2
- // in the loop.
3
- //
4
- // Skills are prompts: they tell a model what to do and the model decides which tools to
5
- // call. A recipe is the other thing — the DECISION already made, as data: "open_bug" is
6
- // `create_issue` with `labels: ["bug"]` baked in and `title`/`body` filled from the
7
- // caller; "triage_pair" fetches two issues at once; "search_then_read" searches, then
8
- // reads the first hit. It runs identically every time, from any client, with no tokens
9
- // spent deciding, and it is the shape the harness spine calls class R: declarative,
10
- // permissioned, never agent-written code. Amendment A2 asked for automations to be
11
- // authored conversationally and approved before they run; this is what gets approved.
12
- //
13
- // Four modes, matching the primitives a round already has: `call` (one tool),
14
- // `parallel` (independent calls, run through tool-round.js so reads overlap), `batch` (one
15
- // tool, many argument sets) and `pipeline` (each step may map values out of the previous
16
- // step's result with `inputMapping`: "$text" for its text, "$json" for it parsed,
17
- // "$json.items.0.key" for a path inside).
18
- //
19
- // Two properties are load-bearing:
20
- // • DRY RUN before side effects — `dryRunRecipe` resolves every parameter and names
21
- // every unknown tool, missing required field and destructive call without executing
22
- // anything. It is the propose step of propose → approve → activate.
23
- // • PARTIAL FAILURE IS RECOVERABLE — a parallel/batch run returns every sibling's result
24
- // plus `failedIndexes`; a pipeline stops at the failing step and returns the outputs
25
- // of the steps before it, plus which mappings resolved and which did not. Nothing is
26
- // retried blindly, and nothing that succeeded is lost.
27
- //
28
- // Pure. Execution, tool specs and traits are injected; nothing here knows a model, a
29
- // window, or where recipes are stored.
30
-
31
- import { runToolRound } from './tool-round.js';
32
- import { toolTraits, needsConfirmation } from './tool-traits.js';
33
-
34
- export const RECIPE_MODES = Object.freeze(['call', 'parallel', 'batch', 'pipeline']);
35
-
36
- export class RecipeError extends Error {
37
- constructor(code, message) { super(message); this.name = 'RecipeError'; this.code = code; }
38
- }
39
-
40
- const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
41
- const isParamRef = (v) => isRecord(v) && typeof v.$param === 'string' && Object.keys(v).every((k) => k === '$param' || k === 'default');
42
-
43
- /** Every `{ "$param": name }` a recipe reads, in first-seen order. */
44
- export function recipeParams(recipe) {
45
- const names = [];
46
- const seen = new Set();
47
- const walk = (v) => {
48
- if (isParamRef(v)) { if (!seen.has(v.$param)) { seen.add(v.$param); names.push({ name: v.$param, required: !('default' in v), default: v.default }); } return; }
49
- if (Array.isArray(v)) v.forEach(walk);
50
- else if (isRecord(v)) Object.values(v).forEach(walk);
51
- };
52
- walk(recipe);
53
- return names;
54
- }
55
-
56
- /** Structural validity — the errors a person authoring one needs to see. */
57
- export function validateRecipe(recipe) {
58
- const errors = [];
59
- if (!isRecord(recipe)) return { ok: false, errors: ['recipe must be an object'] };
60
- if (!recipe.name || typeof recipe.name !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/i.test(recipe.name)) errors.push('name: a short identifier (letters, digits, _ -) is required');
61
- if (!RECIPE_MODES.includes(recipe.mode)) errors.push(`mode: one of ${RECIPE_MODES.join(', ')}`);
62
- const call = (c, where) => {
63
- if (!isRecord(c)) { errors.push(`${where}: must be an object`); return; }
64
- if (!c.tool || typeof c.tool !== 'string') errors.push(`${where}.tool: required`);
65
- if (c.arguments !== undefined && !isRecord(c.arguments)) errors.push(`${where}.arguments: must be an object`);
66
- if (c.inputMapping !== undefined && (!isRecord(c.inputMapping) || Object.values(c.inputMapping).some((m) => typeof m !== 'string' || !/^\$(text|json)(\.[^.\s]+)*$/.test(m)))) {
67
- errors.push(`${where}.inputMapping: values must be "$text", "$json" or "$json.<path>"`);
68
- }
69
- if (c.onMappingMissing !== undefined && !['continue', 'fail'].includes(c.onMappingMissing)) errors.push(`${where}.onMappingMissing: "continue" or "fail"`);
70
- };
71
- switch (recipe.mode) {
72
- case 'call': call(recipe, 'recipe'); break;
73
- case 'parallel':
74
- if (!Array.isArray(recipe.calls) || !recipe.calls.length) errors.push('calls: a non-empty array');
75
- else recipe.calls.forEach((c, i) => call(c, `calls[${i}]`));
76
- break;
77
- case 'batch':
78
- if (!recipe.tool || typeof recipe.tool !== 'string') errors.push('tool: required');
79
- if (!Array.isArray(recipe.items) || !recipe.items.length) errors.push('items: a non-empty array');
80
- else recipe.items.forEach((it, i) => { if (!isRecord(it) || !isRecord(it.arguments)) errors.push(`items[${i}].arguments: must be an object`); });
81
- break;
82
- case 'pipeline':
83
- if (!Array.isArray(recipe.steps) || !recipe.steps.length) errors.push('steps: a non-empty array');
84
- else {
85
- recipe.steps.forEach((s, i) => call(s, `steps[${i}]`));
86
- if (isRecord(recipe.steps[0]) && recipe.steps[0].inputMapping) errors.push('steps[0].inputMapping: the first step has no previous result');
87
- }
88
- break;
89
- default: break;
90
- }
91
- return { ok: errors.length === 0, errors };
92
- }
93
-
94
- /**
95
- * Substitute parameters. Returns the plan — a list of concrete calls in the recipe's mode
96
- * — and the names of any parameters missing without a default. A missing parameter is
97
- * reported, not thrown: the caller decides whether to ask for it or fail.
98
- */
99
- export function expandRecipe(recipe, params = {}) {
100
- const v = validateRecipe(recipe);
101
- if (!v.ok) return { ok: false, errors: v.errors, missing: [], plan: null };
102
- const missing = new Set();
103
- const fill = (x) => {
104
- if (isParamRef(x)) {
105
- if (Object.prototype.hasOwnProperty.call(params, x.$param) && params[x.$param] !== undefined) return params[x.$param];
106
- if ('default' in x) return x.default;
107
- missing.add(x.$param);
108
- return undefined;
109
- }
110
- if (Array.isArray(x)) return x.map(fill);
111
- if (isRecord(x)) {
112
- const out = {};
113
- for (const [k, val] of Object.entries(x)) { const f = fill(val); if (f !== undefined) out[k] = f; }
114
- return out;
115
- }
116
- return x;
117
- };
118
- const one = (c) => ({ tool: c.tool, arguments: fill(c.arguments || {}), ...(c.inputMapping ? { inputMapping: { ...c.inputMapping } } : {}), ...(c.onMappingMissing ? { onMappingMissing: c.onMappingMissing } : {}) });
119
- let calls;
120
- switch (recipe.mode) {
121
- case 'call': calls = [one(recipe)]; break;
122
- case 'parallel': calls = recipe.calls.map(one); break;
123
- case 'batch': calls = recipe.items.map((it) => ({ tool: recipe.tool, arguments: fill(it.arguments) })); break;
124
- case 'pipeline': calls = recipe.steps.map(one); break;
125
- default: calls = [];
126
- }
127
- return { ok: missing.size === 0, errors: [], missing: [...missing], plan: { name: recipe.name, mode: recipe.mode, calls } };
128
- }
129
-
130
- // ── mapping ──────────────────────────────────────────────────────────────────────────
131
-
132
- const textOf = (r) => (typeof r === 'string' ? r : r && typeof r === 'object' && typeof r.text === 'string' ? r.text : r == null ? '' : JSON.stringify(r));
133
-
134
- function parseJson(text) {
135
- try { return { ok: true, value: JSON.parse(text) }; } catch { return { ok: false }; }
136
- }
137
-
138
- /**
139
- * Resolve one step's `inputMapping` against the previous result.
140
- * @returns `{ arguments, mapped: { [arg]: value }, skipped: [{ arg, expr, reason }] }`
141
- */
142
- export function mapInput(previous, mapping = {}, base = {}) {
143
- const args = { ...base };
144
- const mapped = {};
145
- const skipped = [];
146
- const text = textOf(previous);
147
- let json;
148
- for (const [arg, expr] of Object.entries(mapping || {})) {
149
- if (expr === '$text') { args[arg] = text; mapped[arg] = text; continue; }
150
- if (!expr.startsWith('$json')) { skipped.push({ arg, expr, reason: 'unknown expression' }); continue; }
151
- if (json === undefined) json = parseJson(text);
152
- if (!json.ok) { skipped.push({ arg, expr, reason: 'previous result is not JSON' }); continue; }
153
- let cur = json.value;
154
- const path = expr.slice(5).split('.').filter(Boolean);
155
- let found = true;
156
- for (const seg of path) {
157
- if (Array.isArray(cur) && /^\d+$/.test(seg)) cur = cur[Number(seg)];
158
- else if (isRecord(cur) && Object.prototype.hasOwnProperty.call(cur, seg)) cur = cur[seg];
159
- else { found = false; break; }
160
- }
161
- if (!found || cur === undefined) { skipped.push({ arg, expr, reason: 'path not found in previous result' }); continue; }
162
- args[arg] = cur;
163
- mapped[arg] = cur;
164
- }
165
- return { arguments: args, mapped, skipped };
166
- }
167
-
168
- // ── dry run ──────────────────────────────────────────────────────────────────────────
169
-
170
- /**
171
- * Everything a person should know before approving: unknown tools, missing required
172
- * fields, destructive calls, and which arguments a pipeline will only learn at run time.
173
- *
174
- * @param specs the tool specs the runtime will execute against (for existence and
175
- * required fields); omit to skip those checks
176
- * @param traitsOf `(tool) => traits`; defaults to toolTraits on the spec or name
177
- */
178
- export function dryRunRecipe(recipe, params = {}, { specs = null, traitsOf = null } = {}) {
179
- const ex = expandRecipe(recipe, params);
180
- if (!ex.plan) return { ok: false, errors: ex.errors, missing: ex.missing, calls: [], warnings: [], destructive: [] };
181
- const byName = specs ? new Map(specs.filter((s) => s?.name).map((s) => [s.name, s])) : null;
182
- const traits = traitsOf || ((tool) => toolTraits(byName?.get(tool) || tool));
183
- const warnings = [];
184
- const destructive = [];
185
- const calls = ex.plan.calls.map((c, i) => {
186
- const spec = byName?.get(c.tool);
187
- const known = byName ? !!spec : null;
188
- const t = traits(c.tool);
189
- const mappedLater = new Set(Object.keys(c.inputMapping || {}));
190
- const required = Array.isArray(spec?.parameters?.required) ? spec.parameters.required : Array.isArray(spec?.inputSchema?.required) ? spec.inputSchema.required : [];
191
- const missingRequired = required.filter((k) => c.arguments[k] === undefined && !mappedLater.has(k));
192
- const row = { index: i, tool: c.tool, arguments: c.arguments, known, traits: t, missingRequired, mappedLater: [...mappedLater] };
193
- if (known === false) warnings.push({ index: i, code: 'unknown_tool', message: `No tool named "${c.tool}" is available.` });
194
- if (missingRequired.length) warnings.push({ index: i, code: 'missing_required', message: `"${c.tool}" needs ${missingRequired.join(', ')}.` });
195
- if (needsConfirmation(t)) { destructive.push(i); warnings.push({ index: i, code: 'destructive', message: `"${c.tool}" is destructive.` }); }
196
- if (ex.plan.mode === 'pipeline' && i > 0 && !mappedLater.size) warnings.push({ index: i, code: 'no_mapping', message: `steps[${i}] uses nothing from the previous step; was that intended?` });
197
- return row;
198
- });
199
- const blocking = ex.missing.length > 0 || warnings.some((w) => w.code === 'unknown_tool' || w.code === 'missing_required');
200
- return { ok: !blocking, errors: [], missing: ex.missing, plan: ex.plan, calls, warnings, destructive };
201
- }
202
-
203
- // ── run ──────────────────────────────────────────────────────────────────────────────
204
-
205
- const defaultIsError = (r) => {
206
- if (r == null) return false;
207
- if (typeof r === 'string') return /^error:/i.test(r) || /^\{\s*"error"/.test(r);
208
- return typeof r === 'object' && (r.error != null || r.isError === true);
209
- };
210
-
211
- /**
212
- * Run an expanded plan. `execute(tool, arguments, meta)` is the host's toolset executor —
213
- * every guard, redaction and confirmation it wraps applies unchanged, because a recipe is
214
- * a way of composing calls, never a way around them.
215
- */
216
- export async function runPlan(plan, { execute, traitsOf, concurrent, isError = defaultIsError, onStart, onDone } = {}) {
217
- if (typeof execute !== 'function') throw new RecipeError('BAD_RUN', 'execute required');
218
- if (!plan || !Array.isArray(plan.calls)) throw new RecipeError('BAD_PLAN', 'plan.calls required');
219
- const meta = { recipe: plan.name, mode: plan.mode };
220
-
221
- if (plan.mode === 'pipeline') {
222
- const steps = [];
223
- let previous;
224
- for (let i = 0; i < plan.calls.length; i += 1) {
225
- const c = plan.calls[i];
226
- let args = c.arguments;
227
- let mapped = {};
228
- let skipped = [];
229
- if (i > 0 && c.inputMapping) {
230
- const m = mapInput(previous, c.inputMapping, c.arguments);
231
- args = m.arguments; mapped = m.mapped; skipped = m.skipped;
232
- if (skipped.length && c.onMappingMissing === 'fail') {
233
- steps.push({ index: i, tool: c.tool, arguments: args, mappedArguments: mapped, skippedMappings: skipped, result: null, status: 'skipped' });
234
- return { status: 'failed', failedStep: i, reason: 'mapping_missing', steps, finalResult: null };
235
- }
236
- }
237
- onStart?.({ name: c.tool, input: args }, i);
238
- let result;
239
- try { result = await execute(c.tool, args, { ...meta, step: i }); } catch (e) { result = { error: `Tool ${c.tool} failed: ${e?.message || e}` }; }
240
- onDone?.({ name: c.tool, input: args }, i, result);
241
- const failed = isError(result);
242
- steps.push({ index: i, tool: c.tool, arguments: args, mappedArguments: mapped, skippedMappings: skipped, result, status: failed ? 'failed' : 'ok' });
243
- if (failed) return { status: 'failed', failedStep: i, reason: 'tool_error', steps, finalResult: null };
244
- previous = result;
245
- }
246
- return { status: 'completed', steps, finalResult: previous };
247
- }
248
-
249
- const calls = plan.calls.map((c) => ({ name: c.tool, input: c.arguments }));
250
- const round = await runToolRound(calls, {
251
- execute: (call, i) => execute(call.name, call.input, { ...meta, index: i }),
252
- traitsOf: traitsOf ? (c) => traitsOf(c.name) : undefined,
253
- concurrent, isError, onStart, onDone,
254
- });
255
- return { status: round.status, succeeded: round.succeeded, failed: round.failed, failedIndexes: round.failedIndexes, results: round.results, coalesced: round.coalesced };
256
- }
257
-
258
- /** Expand, then run. Missing parameters fail before anything executes. */
259
- export async function runRecipe(recipe, params, options) {
260
- const ex = expandRecipe(recipe, params);
261
- if (!ex.plan) throw new RecipeError('INVALID', ex.errors.join('; '));
262
- if (!ex.ok) throw new RecipeError('MISSING_PARAMS', `missing: ${ex.missing.join(', ')}`);
263
- return runPlan(ex.plan, options);
264
- }
1
+ import{runToolRound as R}from"./tool-round.js";import{toolTraits as O,needsConfirmation as q}from"./tool-traits.js";const j=Object.freeze(["call","parallel","batch","pipeline"]);class M extends Error{constructor(n,l){super(l),this.name="RecipeError",this.code=n}}const c=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),w=e=>c(e)&&typeof e.$param=="string"&&Object.keys(e).every(n=>n==="$param"||n==="default");function J(e){const n=[],l=new Set,s=t=>{if(w(t)){l.has(t.$param)||(l.add(t.$param),n.push({name:t.$param,required:!("default"in t),default:t.default}));return}Array.isArray(t)?t.forEach(s):c(t)&&Object.values(t).forEach(s)};return s(e),n}function S(e){const n=[];if(!c(e))return{ok:!1,errors:["recipe must be an object"]};(!e.name||typeof e.name!="string"||!/^[a-z][a-z0-9_-]{0,63}$/i.test(e.name))&&n.push("name: a short identifier (letters, digits, _ -) is required"),j.includes(e.mode)||n.push(`mode: one of ${j.join(", ")}`);const l=(s,t)=>{if(!c(s)){n.push(`${t}: must be an object`);return}(!s.tool||typeof s.tool!="string")&&n.push(`${t}.tool: required`),s.arguments!==void 0&&!c(s.arguments)&&n.push(`${t}.arguments: must be an object`),s.inputMapping!==void 0&&(!c(s.inputMapping)||Object.values(s.inputMapping).some(p=>typeof p!="string"||!/^\$(text|json)(\.[^.\s]+)*$/.test(p)))&&n.push(`${t}.inputMapping: values must be "$text", "$json" or "$json.<path>"`),s.onMappingMissing!==void 0&&!["continue","fail"].includes(s.onMappingMissing)&&n.push(`${t}.onMappingMissing: "continue" or "fail"`)};switch(e.mode){case"call":l(e,"recipe");break;case"parallel":!Array.isArray(e.calls)||!e.calls.length?n.push("calls: a non-empty array"):e.calls.forEach((s,t)=>l(s,`calls[${t}]`));break;case"batch":(!e.tool||typeof e.tool!="string")&&n.push("tool: required"),!Array.isArray(e.items)||!e.items.length?n.push("items: a non-empty array"):e.items.forEach((s,t)=>{(!c(s)||!c(s.arguments))&&n.push(`items[${t}].arguments: must be an object`)});break;case"pipeline":!Array.isArray(e.steps)||!e.steps.length?n.push("steps: a non-empty array"):(e.steps.forEach((s,t)=>l(s,`steps[${t}]`)),c(e.steps[0])&&e.steps[0].inputMapping&&n.push("steps[0].inputMapping: the first step has no previous result"));break;default:break}return{ok:n.length===0,errors:n}}function x(e,n={}){const l=S(e);if(!l.ok)return{ok:!1,errors:l.errors,missing:[],plan:null};const s=new Set,t=r=>{if(w(r)){if(Object.prototype.hasOwnProperty.call(n,r.$param)&&n[r.$param]!==void 0)return n[r.$param];if("default"in r)return r.default;s.add(r.$param);return}if(Array.isArray(r))return r.map(t);if(c(r)){const m={};for(const[f,o]of Object.entries(r)){const a=t(o);a!==void 0&&(m[f]=a)}return m}return r},p=r=>({tool:r.tool,arguments:t(r.arguments||{}),...r.inputMapping?{inputMapping:{...r.inputMapping}}:{},...r.onMappingMissing?{onMappingMissing:r.onMappingMissing}:{}});let d;switch(e.mode){case"call":d=[p(e)];break;case"parallel":d=e.calls.map(p);break;case"batch":d=e.items.map(r=>({tool:e.tool,arguments:t(r.arguments)}));break;case"pipeline":d=e.steps.map(p);break;default:d=[]}return{ok:s.size===0,errors:[],missing:[...s],plan:{name:e.name,mode:e.mode,calls:d}}}const _=e=>typeof e=="string"?e:e&&typeof e=="object"&&typeof e.text=="string"?e.text:e==null?"":JSON.stringify(e);function E(e){try{return{ok:!0,value:JSON.parse(e)}}catch{return{ok:!1}}}function N(e,n={},l={}){const s={...l},t={},p=[],d=_(e);let r;for(const[m,f]of Object.entries(n||{})){if(f==="$text"){s[m]=d,t[m]=d;continue}if(!f.startsWith("$json")){p.push({arg:m,expr:f,reason:"unknown expression"});continue}if(r===void 0&&(r=E(d)),!r.ok){p.push({arg:m,expr:f,reason:"previous result is not JSON"});continue}let o=r.value;const a=f.slice(5).split(".").filter(Boolean);let i=!0;for(const u of a)if(Array.isArray(o)&&/^\d+$/.test(u))o=o[Number(u)];else if(c(o)&&Object.prototype.hasOwnProperty.call(o,u))o=o[u];else{i=!1;break}if(!i||o===void 0){p.push({arg:m,expr:f,reason:"path not found in previous result"});continue}s[m]=o,t[m]=o}return{arguments:s,mapped:t,skipped:p}}function L(e,n={},{specs:l=null,traitsOf:s=null}={}){const t=x(e,n);if(!t.plan)return{ok:!1,errors:t.errors,missing:t.missing,calls:[],warnings:[],destructive:[]};const p=l?new Map(l.filter(a=>a?.name).map(a=>[a.name,a])):null,d=s||(a=>O(p?.get(a)||a)),r=[],m=[],f=t.plan.calls.map((a,i)=>{const u=p?.get(a.tool),g=p?!!u:null,k=d(a.tool),h=new Set(Object.keys(a.inputMapping||{})),$=(Array.isArray(u?.parameters?.required)?u.parameters.required:Array.isArray(u?.inputSchema?.required)?u.inputSchema.required:[]).filter(A=>a.arguments[A]===void 0&&!h.has(A)),y={index:i,tool:a.tool,arguments:a.arguments,known:g,traits:k,missingRequired:$,mappedLater:[...h]};return g===!1&&r.push({index:i,code:"unknown_tool",message:`No tool named "${a.tool}" is available.`}),$.length&&r.push({index:i,code:"missing_required",message:`"${a.tool}" needs ${$.join(", ")}.`}),q(k)&&(m.push(i),r.push({index:i,code:"destructive",message:`"${a.tool}" is destructive.`})),t.plan.mode==="pipeline"&&i>0&&!h.size&&r.push({index:i,code:"no_mapping",message:`steps[${i}] uses nothing from the previous step; was that intended?`}),y});return{ok:!(t.missing.length>0||r.some(a=>a.code==="unknown_tool"||a.code==="missing_required")),errors:[],missing:t.missing,plan:t.plan,calls:f,warnings:r,destructive:m}}const I=e=>e==null?!1:typeof e=="string"?/^error:/i.test(e)||/^\{\s*"error"/.test(e):typeof e=="object"&&(e.error!=null||e.isError===!0);async function P(e,{execute:n,traitsOf:l,concurrent:s,isError:t=I,onStart:p,onDone:d}={}){if(typeof n!="function")throw new M("BAD_RUN","execute required");if(!e||!Array.isArray(e.calls))throw new M("BAD_PLAN","plan.calls required");const r={recipe:e.name,mode:e.mode};if(e.mode==="pipeline"){const o=[];let a;for(let i=0;i<e.calls.length;i+=1){const u=e.calls[i];let g=u.arguments,k={},h=[];if(i>0&&u.inputMapping){const y=N(a,u.inputMapping,u.arguments);if(g=y.arguments,k=y.mapped,h=y.skipped,h.length&&u.onMappingMissing==="fail")return o.push({index:i,tool:u.tool,arguments:g,mappedArguments:k,skippedMappings:h,result:null,status:"skipped"}),{status:"failed",failedStep:i,reason:"mapping_missing",steps:o,finalResult:null}}p?.({name:u.tool,input:g},i);let b;try{b=await n(u.tool,g,{...r,step:i})}catch(y){b={error:`Tool ${u.tool} failed: ${y?.message||y}`}}d?.({name:u.tool,input:g},i,b);const $=t(b);if(o.push({index:i,tool:u.tool,arguments:g,mappedArguments:k,skippedMappings:h,result:b,status:$?"failed":"ok"}),$)return{status:"failed",failedStep:i,reason:"tool_error",steps:o,finalResult:null};a=b}return{status:"completed",steps:o,finalResult:a}}const m=e.calls.map(o=>({name:o.tool,input:o.arguments})),f=await R(m,{execute:(o,a)=>n(o.name,o.input,{...r,index:a}),traitsOf:l?o=>l(o.name):void 0,concurrent:s,isError:t,onStart:p,onDone:d});return{status:f.status,succeeded:f.succeeded,failed:f.failed,failedIndexes:f.failedIndexes,results:f.results,coalesced:f.coalesced}}async function B(e,n,l){const s=x(e,n);if(!s.plan)throw new M("INVALID",s.errors.join("; "));if(!s.ok)throw new M("MISSING_PARAMS",`missing: ${s.missing.join(", ")}`);return P(s.plan,l)}export{j as RECIPE_MODES,M as RecipeError,L as dryRunRecipe,x as expandRecipe,N as mapInput,J as recipeParams,P as runPlan,B as runRecipe,S as validateRecipe};
package/record-list.js CHANGED
@@ -1,110 +1,2 @@
1
- // A list of records as a person reads it: which order, which ones, and under which heading.
2
- //
3
- // The desktop showed 300 rows newest-modified-first with a date and nothing else, and it read
4
- // as unsorted — twelve rows saying "Sep 12" are indistinguishable, "modified" is not the
5
- // date a person remembers a chat by, and nothing separated today from last month. The query
6
- // was right; the reading was impossible. These are the rules that make the same rows
7
- // legible, kept out of the client because every list of records — the extension's history,
8
- // a phone's — has to answer the same three questions the same way.
9
- //
10
- // Pure: `now` is injected, and a record needs only `{ title, snippet?, updatedAt, createdAt }`.
11
-
12
- export const SORT_MODES = Object.freeze(['recent', 'started', 'title']);
13
-
14
- export const SORT_LABELS = Object.freeze({
15
- recent: 'Recently active',
16
- started: 'Date started',
17
- title: 'Title A–Z',
18
- });
19
-
20
- const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
21
-
22
- /** The timestamp a sort mode reads — modified for `recent`, created for `started`. */
23
- export function sortStamp(rec, mode = 'recent') {
24
- if (mode === 'started') return num(rec?.createdAt) || num(rec?.updatedAt);
25
- return num(rec?.updatedAt) || num(rec?.createdAt);
26
- }
27
-
28
- export function sortRecords(items, mode = 'recent') {
29
- const list = Array.isArray(items) ? [...items] : [];
30
- if (mode === 'title') {
31
- return list.sort((a, b) => String(a?.title || '').localeCompare(String(b?.title || ''), undefined, { sensitivity: 'base' }) || sortStamp(b) - sortStamp(a));
32
- }
33
- const m = SORT_MODES.includes(mode) ? mode : 'recent';
34
- return list.sort((a, b) => sortStamp(b, m) - sortStamp(a, m));
35
- }
36
-
37
- /**
38
- * Keep the rows every word of the query appears in — title or snippet, any order, any case.
39
- * A query of nothing keeps everything, so a filter box can be bound to it directly.
40
- */
41
- export function filterRecords(items, query) {
42
- const words = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
43
- const list = Array.isArray(items) ? items : [];
44
- if (!words.length) return list;
45
- return list.filter((r) => {
46
- const hay = `${r?.title || ''}\n${r?.snippet || ''}`.toLowerCase();
47
- return words.every((w) => hay.includes(w));
48
- });
49
- }
50
-
51
- const DAY = 86_400_000;
52
-
53
- function startOfDay(ts) {
54
- const d = new Date(ts);
55
- d.setHours(0, 0, 0, 0);
56
- return d.getTime();
57
- }
58
-
59
- const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
60
- const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
61
-
62
- /** Which heading a timestamp files under, relative to `now`. */
63
- export function dayBucket(ts, now = Date.now()) {
64
- const t = num(ts);
65
- if (!t) return { key: 'undated', label: 'Undated', order: 9e15 };
66
- const today = startOfDay(now);
67
- const day = startOfDay(t);
68
- const daysAgo = Math.round((today - day) / DAY);
69
- if (daysAgo <= 0) return { key: 'today', label: 'Today', order: 0 };
70
- if (daysAgo === 1) return { key: 'yesterday', label: 'Yesterday', order: 1 };
71
- if (daysAgo < 7) return { key: 'week', label: 'Earlier this week', order: 2 };
72
- const d = new Date(t);
73
- const n = new Date(now);
74
- if (d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth()) return { key: 'month', label: 'Earlier this month', order: 3 };
75
- const label = d.getFullYear() === n.getFullYear() ? MONTHS[d.getMonth()] : `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
76
- return { key: `m-${d.getFullYear()}-${d.getMonth()}`, label, order: 4 + (n.getFullYear() * 12 + n.getMonth()) - (d.getFullYear() * 12 + d.getMonth()) };
77
- }
78
-
79
- /**
80
- * The time a row shows, sized to how far away it is: a clock today and yesterday, a weekday
81
- * this week, a short date after that. Inside a day group the clock is the only thing that
82
- * tells two rows apart — which is the whole reason the desktop's list looked unsorted.
83
- */
84
- export function rowTime(ts, now = Date.now(), { locale = undefined } = {}) {
85
- const t = num(ts);
86
- if (!t) return '';
87
- const b = dayBucket(t, now);
88
- const d = new Date(t);
89
- if (b.key === 'today' || b.key === 'yesterday') return d.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' });
90
- if (b.key === 'week') return DAYS[d.getDay()];
91
- const sameYear = d.getFullYear() === new Date(now).getFullYear();
92
- return d.toLocaleDateString(locale, { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }) });
93
- }
94
-
95
- /**
96
- * Sort, filter and group in one pass: `[{ key, label, items }]`, groups in reading order.
97
- * With `mode: 'title'` there is one group and no heading, because an alphabetical list has
98
- * nothing to do with days.
99
- */
100
- export function groupRecords(items, { mode = 'recent', query = '', now = Date.now() } = {}) {
101
- const sorted = sortRecords(filterRecords(items, query), mode);
102
- if (mode === 'title') return sorted.length ? [{ key: 'all', label: '', items: sorted }] : [];
103
- const groups = new Map();
104
- for (const r of sorted) {
105
- const b = dayBucket(sortStamp(r, mode), now);
106
- if (!groups.has(b.key)) groups.set(b.key, { key: b.key, label: b.label, order: b.order, items: [] });
107
- groups.get(b.key).items.push(r);
108
- }
109
- return [...groups.values()].sort((a, b) => a.order - b.order).map(({ order, ...g }) => g);
110
- }
1
+ const f=Object.freeze(["recent","started","title"]),k=Object.freeze({recent:"Recently active",started:"Date started",title:"Title A–Z"}),i=e=>Number.isFinite(Number(e))?Number(e):0;function l(e,t="recent"){return t==="started"?i(e?.createdAt)||i(e?.updatedAt):i(e?.updatedAt)||i(e?.createdAt)}function h(e,t="recent"){const s=Array.isArray(e)?[...e]:[];if(t==="title")return s.sort((o,r)=>String(o?.title||"").localeCompare(String(r?.title||""),void 0,{sensitivity:"base"})||l(r)-l(o));const u=f.includes(t)?t:"recent";return s.sort((o,r)=>l(r,u)-l(o,u))}function p(e,t){const s=String(t||"").toLowerCase().split(/\s+/).filter(Boolean),u=Array.isArray(e)?e:[];return s.length?u.filter(o=>{const r=`${o?.title||""}
2
+ ${o?.snippet||""}`.toLowerCase();return s.every(n=>r.includes(n))}):u}const b=864e5;function c(e){const t=new Date(e);return t.setHours(0,0,0,0),t.getTime()}const d=["January","February","March","April","May","June","July","August","September","October","November","December"],m=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];function y(e,t=Date.now()){const s=i(e);if(!s)return{key:"undated",label:"Undated",order:9e15};const u=c(t),o=c(s),r=Math.round((u-o)/b);if(r<=0)return{key:"today",label:"Today",order:0};if(r===1)return{key:"yesterday",label:"Yesterday",order:1};if(r<7)return{key:"week",label:"Earlier this week",order:2};const n=new Date(s),a=new Date(t);if(n.getFullYear()===a.getFullYear()&&n.getMonth()===a.getMonth())return{key:"month",label:"Earlier this month",order:3};const g=n.getFullYear()===a.getFullYear()?d[n.getMonth()]:`${d[n.getMonth()]} ${n.getFullYear()}`;return{key:`m-${n.getFullYear()}-${n.getMonth()}`,label:g,order:4+(a.getFullYear()*12+a.getMonth())-(n.getFullYear()*12+n.getMonth())}}function D(e,t=Date.now(),{locale:s=void 0}={}){const u=i(e);if(!u)return"";const o=y(u,t),r=new Date(u);if(o.key==="today"||o.key==="yesterday")return r.toLocaleTimeString(s,{hour:"numeric",minute:"2-digit"});if(o.key==="week")return m[r.getDay()];const n=r.getFullYear()===new Date(t).getFullYear();return r.toLocaleDateString(s,{month:"short",day:"numeric",...n?{}:{year:"numeric"}})}function A(e,{mode:t="recent",query:s="",now:u=Date.now()}={}){const o=h(p(e,s),t);if(t==="title")return o.length?[{key:"all",label:"",items:o}]:[];const r=new Map;for(const n of o){const a=y(l(n,t),u);r.has(a.key)||r.set(a.key,{key:a.key,label:a.label,order:a.order,items:[]}),r.get(a.key).items.push(n)}return[...r.values()].sort((n,a)=>n.order-a.order).map(({order:n,...a})=>a)}export{k as SORT_LABELS,f as SORT_MODES,y as dayBucket,p as filterRecords,A as groupRecords,D as rowTime,h as sortRecords,l as sortStamp};