@chatpanel/events 0.70.0 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cowriter-writer.js +199 -0
- package/index.js +3 -1
- package/package.json +5 -1
- package/skill-vars.js +16 -0
- package/slash-commands.js +133 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// cowriter-writer.js — the WRITER's half of the co-writer: when it may draft, what it is
|
|
2
|
+
// asked, and how much it may spend.
|
|
3
|
+
//
|
|
4
|
+
// The Editor's half (lint, diff, the copy-editor prompt) is cowriter.js. This is the member
|
|
5
|
+
// that WRITES — a continuation from the caret on ⌘↵, a section under a heading you paused on,
|
|
6
|
+
// the next line toward a goal, or the result of an instruction typed into the note — and it
|
|
7
|
+
// lived as a dozen functions inside the extension's notes.js until the desktop needed the
|
|
8
|
+
// same member. Everything here is a decision or a string; the ghost text, the keys and the
|
|
9
|
+
// stream are the client's.
|
|
10
|
+
//
|
|
11
|
+
// The standing rule of this member: IT OFFERS, THE USER ACCEPTS. Every draft is a suggestion
|
|
12
|
+
// with an accept and a reject, and nothing here writes into a document.
|
|
13
|
+
|
|
14
|
+
// ── What the cursor line affords ─────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/** The line the caret is on, as `{ text, start, end }` — `end` excludes the newline. */
|
|
17
|
+
export function lineAt(text, caret) {
|
|
18
|
+
const src = String(text ?? '');
|
|
19
|
+
const pos = Math.max(0, Math.min(caret ?? 0, src.length));
|
|
20
|
+
const start = src.lastIndexOf('\n', pos - 1) + 1;
|
|
21
|
+
const nl = src.indexOf('\n', pos);
|
|
22
|
+
const end = nl < 0 ? src.length : nl;
|
|
23
|
+
return { text: src.slice(start, end), start, end };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the cursor line affords the Writer: an empty outline item, a TODO marker, or a
|
|
28
|
+
* heading whose section has no body yet → `{ kind, at, label }`, or null. Detecting the
|
|
29
|
+
* spot is free; drafting it is a model call, and only happens on a nudge, ⌘↵ or Focus.
|
|
30
|
+
*/
|
|
31
|
+
export function writerAffordance(text, caret) {
|
|
32
|
+
const v = String(text ?? '');
|
|
33
|
+
const line = lineAt(v, caret);
|
|
34
|
+
const t = line.text;
|
|
35
|
+
if (/^\s*([-*]|\d+\.)\s*(\[ \]\s*)?$/.test(t)) return { kind: 'item', at: line.end, label: 'this item' };
|
|
36
|
+
if (/\b(TODO|TK|TBD)\b:?\s*$/i.test(t)) return { kind: 'todo', at: line.end, label: 'this to-do' };
|
|
37
|
+
if (/^#{1,6}\s+\S/.test(t)) { // a heading whose section has no body yet
|
|
38
|
+
const next = v.slice(line.end).replace(/^\n/, '').split('\n', 1)[0] || '';
|
|
39
|
+
if (!next.trim() || /^#{1,6}\s/.test(next)) return { kind: 'section', at: line.end, label: 'this section' };
|
|
40
|
+
}
|
|
41
|
+
if (!t.trim()) { // a blank line directly under a heading
|
|
42
|
+
const before = v.slice(0, line.start).replace(/\n$/, '');
|
|
43
|
+
const prev = before.slice(before.lastIndexOf('\n') + 1);
|
|
44
|
+
if (/^#{1,6}\s+\S/.test(prev)) return { kind: 'section', at: line.start, label: 'this section' };
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* An imperative line the user wants ACTED on — "summarize the above in 3 sentences", "list
|
|
51
|
+
* the key risks", "rewrite this as bullets" — as distinct from prose to continue. A
|
|
52
|
+
* conservative verb-led match, never an @mention or a /command line (those run elsewhere).
|
|
53
|
+
*/
|
|
54
|
+
export const INSTRUCTION_RE = /^\s*(?:please\s+|can you\s+|now\s+)?(summari[sz]e|recap|tl;?dr|rewrite|re-?write|reword|rephrase|expand|elaborate|continue|list|enumerate|outline|draft|write|compose|generate|create|add|explain|describe|define|compare|contrast|shorten|condense|tighten|simplify|translate|convert|turn\s+.+\s+into|make\s+(?:this|it|these|a\b)|bullet|brainstorm|suggest|proofread|polish|improve)\b/i;
|
|
55
|
+
|
|
56
|
+
export function instructionOnLine(text, caret) {
|
|
57
|
+
const line = lineAt(text, caret);
|
|
58
|
+
const t = line.text.trim();
|
|
59
|
+
if (t.length < 6) return null;
|
|
60
|
+
if (/^[@/]/.test(t) || /@\[[^\]]+\]/.test(t)) return null;
|
|
61
|
+
if (!INSTRUCTION_RE.test(t)) return null;
|
|
62
|
+
return { text: t, start: line.start, end: line.end };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Goal-drive ───────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/** Chars of the user's OWN writing required between two automatic drafts. */
|
|
68
|
+
export const GOAL_MIN_NEW = 24;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* May the goal draft the next line now?
|
|
72
|
+
*
|
|
73
|
+
* It must NOT loop: without this guard it re-fires on every pause (each Enter changes the
|
|
74
|
+
* length) and re-drafts near-duplicates. So it fires at most once per burst of the user's
|
|
75
|
+
* own writing — the document must have grown by `minNew` chars since the last draft, the
|
|
76
|
+
* caret must sit at a line end, and there must be real context to continue. `lastLen` is
|
|
77
|
+
* the body length at the last draft (-1 = armed: fire once there is context); the caller
|
|
78
|
+
* re-baselines it when a draft is accepted, so an accepted line does not trigger the next.
|
|
79
|
+
*/
|
|
80
|
+
export function goalDraftAllowed({ text, caret, lastLen = -1, minNew = GOAL_MIN_NEW } = {}) {
|
|
81
|
+
const v = String(text ?? '');
|
|
82
|
+
const from = Math.max(0, Math.min(caret ?? 0, v.length));
|
|
83
|
+
if (from !== v.length && v[from] !== '\n') return false;
|
|
84
|
+
if (v.slice(0, from).trim().length < 24) return false;
|
|
85
|
+
if (lastLen >= 0 && v.length <= lastLen + minNew) return false;
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Spend ────────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A rolling per-minute cap on MODEL calls the team makes on its own. Free work — the
|
|
93
|
+
* deterministic Editor pass, retrieval-only research — never counts; over the cap the
|
|
94
|
+
* spending members skip until the window clears. Visible, so "why did it stop" has an answer.
|
|
95
|
+
*/
|
|
96
|
+
export function createSpendMeter({ capPerMin = 20, now = Date.now } = {}) {
|
|
97
|
+
let calls = [];
|
|
98
|
+
const prune = () => { const t = now(); calls = calls.filter((c) => t - c < 60_000); };
|
|
99
|
+
return {
|
|
100
|
+
cap: capPerMin,
|
|
101
|
+
ok() { prune(); return calls.length < capPerMin; },
|
|
102
|
+
spend() { calls.push(now()); },
|
|
103
|
+
used() { prune(); return calls.length; },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Prompts ──────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** The tail of the note the Writer continues from — capped, with the title as context. */
|
|
110
|
+
export function writerTail(before, title = '') {
|
|
111
|
+
const b = String(before ?? '');
|
|
112
|
+
const tail = b.length > 1600 ? `…${b.slice(-1600)}` : b;
|
|
113
|
+
const t = String(title || '').trim();
|
|
114
|
+
return (t ? `# ${t}\n\n` : '') + tail;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Where an instruction's result goes: on its own line under the instruction. */
|
|
118
|
+
export function draftSeparator(before) {
|
|
119
|
+
const b = String(before ?? '');
|
|
120
|
+
return b.endsWith('\n\n') ? '' : b.endsWith('\n') ? '\n' : '\n\n';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The Researcher's shelf, as the lines the Writer may draw on — a handoff, not a dump. */
|
|
124
|
+
export function groundingBlock(cards = [], { limit = 5 } = {}) {
|
|
125
|
+
const list = (Array.isArray(cards) ? cards : []).filter((c) => c && c.title).slice(0, limit);
|
|
126
|
+
if (!list.length) return '';
|
|
127
|
+
return '\n\nRelated material you may draw on (only if genuinely useful — cite as [[title]] or [text](url)):\n'
|
|
128
|
+
+ list.map((c) => `- ${c.title}${c.snippet ? ` — ${c.snippet}` : ''}`).join('\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The Writer's request: a continuation from where the note stops, or an instruction executed
|
|
133
|
+
* over the note above it. -> { system, user, maxTokens, temperature }
|
|
134
|
+
*/
|
|
135
|
+
export function writerRequest({ before, title = '', instruction = '', contextBefore = null, intent = '', cards = [] } = {}) {
|
|
136
|
+
const grounding = groundingBlock(cards);
|
|
137
|
+
const goal = String(intent || '').trim();
|
|
138
|
+
if (instruction) {
|
|
139
|
+
return {
|
|
140
|
+
system: `You are executing an instruction inside the user's note. Use the note as context and do EXACTLY what the instruction says. Match the note's voice, tone, and markdown style. Output ONLY the resulting markdown to insert — no preamble, no restating the instruction, no meta commentary.${goal ? ` The note's goal: ${goal}.` : ''}${grounding}`,
|
|
141
|
+
user: `NOTE SO FAR:\n${writerTail(contextBefore ?? before, title)}\n\nINSTRUCTION (do exactly this, output only the result):\n${instruction}`,
|
|
142
|
+
maxTokens: 600,
|
|
143
|
+
temperature: 0.4,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
system: `${goal ? `The note's goal (guide your writing toward it): ${goal}.\n\n` : ''}You continue the user's note from where it stops. Match their voice, tone, and markdown style exactly. Write only the NEXT one or two sentences (or finish the current one) — concise, natural, no preamble, no repetition of prior text, no meta commentary. Output ONLY the continuation.${grounding}`,
|
|
148
|
+
user: writerTail(before, title),
|
|
149
|
+
maxTokens: 220,
|
|
150
|
+
temperature: 0.6,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Inline autocomplete: a few words, at most one sentence, at the very end of the note. */
|
|
155
|
+
export const AUTOCOMPLETE_SYSTEM = 'You are an inline writing autocomplete. Continue the note from EXACTLY where it stops with a SHORT continuation — a few words up to one sentence. Match the voice and markdown. Output ONLY the text to append: no quotes, no preamble, no repetition of prior text. If nothing sensible follows, output nothing.';
|
|
156
|
+
export const AUTOCOMPLETE_MAX_TOKENS = 48;
|
|
157
|
+
export const AUTOCOMPLETE_TEMPERATURE = 0.1;
|
|
158
|
+
|
|
159
|
+
/** Keep a completion short: the first line, and at most the first sentence of it. */
|
|
160
|
+
export function clipCompletion(s) {
|
|
161
|
+
let t = String(s ?? '').replace(/^\s+/, '');
|
|
162
|
+
const nl = t.indexOf('\n');
|
|
163
|
+
if (nl >= 0) t = t.slice(0, nl);
|
|
164
|
+
const m = t.match(/^.*?[.!?](\s|$)/);
|
|
165
|
+
if (m) t = m[0];
|
|
166
|
+
return t.replace(/\s+$/, '');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── The switches, in one voice ───────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/** The two gears — and what each DOES to your text, which is the question. */
|
|
172
|
+
export const GEARS = Object.freeze([
|
|
173
|
+
Object.freeze({ id: 'ambient', name: 'Suggest', note: 'Nothing is written for you. Typo fixes, links and research collect quietly in the Co-writer tab, and you decide what to take. Press ⌘↵ any time to draft ahead.' }),
|
|
174
|
+
Object.freeze({ id: 'focus', name: 'Write with me', note: 'The team writes alongside you: it drafts a section when you pause on a heading, and every draft is still accept-or-reject. The per-minute spend cap still applies.' }),
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
/** The opt-ins. Each is a model call the team may make WITHOUT being asked, so each is off. */
|
|
178
|
+
export const WRITER_PREFS = Object.freeze([
|
|
179
|
+
Object.freeze({ k: 'revealFixes', label: 'Show fixes as they land', desc: 'Open the Co-writer tab when a typo fix or a draft is ready — no hunting for the badge.', spends: false }),
|
|
180
|
+
Object.freeze({ k: 'actOnInstructions', label: 'Act on instruction lines', desc: 'A line like “summarize the above in 3 sentences” becomes a task the Writer drafts (accept/reject).', spends: true }),
|
|
181
|
+
Object.freeze({ k: 'goalDrive', label: 'Let the goal keep writing', desc: 'With a goal set, draft the next line toward it whenever you pause at a line end. Always accept/reject; respects the spend cap.', spends: true }),
|
|
182
|
+
Object.freeze({ k: 'autocomplete', label: 'Autocomplete as I type', desc: 'A short ghost continuation at the end of the note after a pause — one small call per pause, Tab keeps it. Counts against the spend cap.', spends: true }),
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
export const WRITER_PREF_DEFAULTS = Object.freeze({ gear: 'ambient', revealFixes: false, actOnInstructions: false, goalDrive: false, autocomplete: false });
|
|
186
|
+
|
|
187
|
+
/** A stored prefs object, with unknown keys dropped and missing ones defaulted. */
|
|
188
|
+
export function normalizeWriterPrefs(stored) {
|
|
189
|
+
const s = stored && typeof stored === 'object' ? stored : {};
|
|
190
|
+
const out = { ...WRITER_PREF_DEFAULTS };
|
|
191
|
+
out.gear = s.gear === 'focus' ? 'focus' : 'ambient';
|
|
192
|
+
for (const p of WRITER_PREFS) out[p.k] = s[p.k] === true;
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A note's goal, as stored: one line, capped. */
|
|
197
|
+
export function normalizeIntent(v) {
|
|
198
|
+
return String(v || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
199
|
+
}
|
package/index.js
CHANGED
|
@@ -201,7 +201,9 @@ export {
|
|
|
201
201
|
} from './memory.js';
|
|
202
202
|
export { SOURCE_TRUST, SkillSourceError, defineSkillSource, createSkillSourceRegistry } from './skill-sources.js';
|
|
203
203
|
export { SKILL_MANIFEST_VERSION, SKILL_CONTEXTS, SKILL_HISTORY_SCOPES, SKILL_MCP_MODES, SKILL_TRUST, SKILL_FILE_KINDS, SKILL_UPCASTERS, SkillManifestError, isSafeSkillPath, originOf, trustOf, skillFiles, needsBridge, declaredAccess, originLabel, sameSkillOrigin, skillIsStale, validateSkill, upcastSkill, upcastSkills, normalizeSkill } from './skill-manifest.js';
|
|
204
|
-
export { SKILL_VARS, SKILL_VAR_NAMES, skillVar, skillVarPattern, parseSkillVars, lintSkillPrompt, suggestSkillVar, substituteSkillVars, skillVarGuidance, SkillVarError } from './skill-vars.js';
|
|
204
|
+
export { SKILL_VARS, SKILL_VAR_NAMES, skillVar, skillVarPattern, parseSkillVars, lintSkillPrompt, suggestSkillVar, substituteSkillVars, expandSkillPrompt, skillVarGuidance, SkillVarError } from './skill-vars.js';
|
|
205
|
+
export { lineAt, writerAffordance, INSTRUCTION_RE, instructionOnLine, GOAL_MIN_NEW, goalDraftAllowed, createSpendMeter, writerTail, draftSeparator, groundingBlock, writerRequest, AUTOCOMPLETE_SYSTEM, AUTOCOMPLETE_MAX_TOKENS, AUTOCOMPLETE_TEMPERATURE, clipCompletion, GEARS, WRITER_PREFS, WRITER_PREF_DEFAULTS, normalizeWriterPrefs, normalizeIntent } from './cowriter-writer.js';
|
|
206
|
+
export { SLASH_TYPING_RE, enabledSkills, slashCommandItems, matchSlashSkill, matchSlashRecipe, recipeInvocationText, slashCommandInsert, skillInvocationOf, skillInvocationLabel } from './slash-commands.js';
|
|
205
207
|
export { outlineOf, parseListItem, continueList, indentSelection, toggleWrap, toggleLinePrefix, toggleTask, toggleLink, docStats, selectionStats } from './markdown-authoring.js';
|
|
206
208
|
export {
|
|
207
209
|
MAX_TAG_LENGTH, MAX_TAGS, normalizeTag, normalizeTags, hasTag, addTag, removeTag, toggleTag,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts \u2014 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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"./citations.js": "./citations.js",
|
|
14
14
|
"./cowriter-router.js": "./cowriter-router.js",
|
|
15
15
|
"./cowriter.js": "./cowriter.js",
|
|
16
|
+
"./cowriter-writer.js": "./cowriter-writer.js",
|
|
16
17
|
"./curate.js": "./curate.js",
|
|
17
18
|
"./distance.js": "./distance.js",
|
|
18
19
|
"./entitlement.js": "./entitlement.js",
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
"./skill-scan.js": "./skill-scan.js",
|
|
68
69
|
"./skill-sources.js": "./skill-sources.js",
|
|
69
70
|
"./skill-vars.js": "./skill-vars.js",
|
|
71
|
+
"./slash-commands.js": "./slash-commands.js",
|
|
70
72
|
"./sources-retrieval.js": "./sources-retrieval.js",
|
|
71
73
|
"./sources.js": "./sources.js",
|
|
72
74
|
"./store.js": "./store.js",
|
|
@@ -121,6 +123,7 @@
|
|
|
121
123
|
"context-attachments.js",
|
|
122
124
|
"cowriter-router.js",
|
|
123
125
|
"cowriter.js",
|
|
126
|
+
"cowriter-writer.js",
|
|
124
127
|
"curate.js",
|
|
125
128
|
"distance.js",
|
|
126
129
|
"entitlement.js",
|
|
@@ -179,6 +182,7 @@
|
|
|
179
182
|
"skill-scan.js",
|
|
180
183
|
"skill-sources.js",
|
|
181
184
|
"skill-vars.js",
|
|
185
|
+
"slash-commands.js",
|
|
182
186
|
"sources-retrieval.js",
|
|
183
187
|
"sources.js",
|
|
184
188
|
"store.js",
|
package/skill-vars.js
CHANGED
|
@@ -224,6 +224,22 @@ export async function substituteSkillVars(text, { args = '', resolvers = {} } =
|
|
|
224
224
|
return { text: out, filled, empty, unknown: lint.unknown };
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* The prompt a `/command args` sends.
|
|
229
|
+
*
|
|
230
|
+
* The typed args fill `{{input}}` when the author left a slot for them, and are appended
|
|
231
|
+
* after the prompt when not — never both, which is how "/fix this sentence" once landed
|
|
232
|
+
* in the prompt twice. Everything else `substituteSkillVars` reports (an empty selection,
|
|
233
|
+
* an invented placeholder) comes back untouched, so the caller can say so out loud.
|
|
234
|
+
*/
|
|
235
|
+
export async function expandSkillPrompt(prompt, { args = '', resolvers = {} } = {}) {
|
|
236
|
+
const src = String(prompt || '');
|
|
237
|
+
const a = String(args || '').trim();
|
|
238
|
+
const inline = lintSkillPrompt(src).hasInput;
|
|
239
|
+
const body = src + (!inline && a ? `\n\n${a}` : '');
|
|
240
|
+
return substituteSkillVars(body, { args: a, resolvers });
|
|
241
|
+
}
|
|
242
|
+
|
|
227
243
|
/**
|
|
228
244
|
* The sentence prompt-assist needs so the model stops inventing placeholders.
|
|
229
245
|
* Generated from SKILL_VARS so a variable added here reaches the assist prompt
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// slash-commands.js — the `/command` grammar of a chat composer, declared ONCE.
|
|
2
|
+
//
|
|
3
|
+
// A skill, a saved recipe and a client's built-in command all answer to a slash. Which
|
|
4
|
+
// ones exist, how a half-typed prefix is matched, what a chosen item inserts, and how a
|
|
5
|
+
// sent run is LABELLED all lived in the extension's side panel, and the desktop had to copy
|
|
6
|
+
// them the day its composer wanted `/summarize` too. This module is that copy, made the
|
|
7
|
+
// original: a third client (mobile, a channel bot) inherits the grammar instead of
|
|
8
|
+
// re-deriving it.
|
|
9
|
+
//
|
|
10
|
+
// What is NOT here is deliberate:
|
|
11
|
+
// • the BUILT-IN list — `/search`, `/history`, `/monitor` — is what a client can DO,
|
|
12
|
+
// and a desktop with no live meeting has no `/tldr`. Each client passes its own.
|
|
13
|
+
// • the prompt expansion — `expandSkillPrompt` in skill-vars.js, so a composer that only
|
|
14
|
+
// needs the menu never pays for the variable layer.
|
|
15
|
+
// • the entitlement — `skillsAllowed` is decided by the caller against its licence.
|
|
16
|
+
|
|
17
|
+
function normalizePrefix(prefix) {
|
|
18
|
+
return String(prefix || '')
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/^\//, '')
|
|
21
|
+
.replace(/\s+/g, ' ');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function skillItem(skill) {
|
|
25
|
+
return {
|
|
26
|
+
type: 'skill',
|
|
27
|
+
command: skill.command || '',
|
|
28
|
+
icon: skill.icon || '🎓',
|
|
29
|
+
description: skill.description || skill.name || '',
|
|
30
|
+
skill,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A saved recipe is a /command too. It is not a skill: nothing is expanded into a prompt.
|
|
35
|
+
// The line becomes a plain request to run it, and the `recipe` tool does the rest.
|
|
36
|
+
function recipeItem(recipe) {
|
|
37
|
+
return { type: 'recipe', command: recipe.name || '', icon: '🧩', description: recipe.description || 'Saved recipe', recipe };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Skills that are switched on. Absence of the flag means enabled (older records have none). */
|
|
41
|
+
export function enabledSkills(skills) {
|
|
42
|
+
return (Array.isArray(skills) ? skills : []).filter((s) => !!s && s.enabled !== false);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The items a composer offers for what has been typed so far.
|
|
47
|
+
*
|
|
48
|
+
* @param builtins the client's own commands — `{ command, icon, description, feature? }`.
|
|
49
|
+
* One with a `feature` is shown LOCKED when `features[feature]` is
|
|
50
|
+
* false: a Free user should still discover what Pro unlocks.
|
|
51
|
+
* @param skills the user's skills; only enabled ones with a command are offered
|
|
52
|
+
* @param recipes saved recipes; enabled ones by name
|
|
53
|
+
* @param prefix what follows the slash, possibly with a subcommand ("history m")
|
|
54
|
+
* @param skillsAllowed whether skills are offered at all (Pro on the extension)
|
|
55
|
+
* @param features `{ liveMeetings: true }` — what the licence unlocks
|
|
56
|
+
*/
|
|
57
|
+
export function slashCommandItems({
|
|
58
|
+
builtins = [],
|
|
59
|
+
skills = [],
|
|
60
|
+
recipes = [],
|
|
61
|
+
prefix = '',
|
|
62
|
+
skillsAllowed = false,
|
|
63
|
+
features = {},
|
|
64
|
+
} = {}) {
|
|
65
|
+
const normalized = normalizePrefix(prefix);
|
|
66
|
+
const own = (Array.isArray(builtins) ? builtins : []).map((item) => ({
|
|
67
|
+
type: 'builtin',
|
|
68
|
+
...item,
|
|
69
|
+
locked: !!item.feature && !features[item.feature],
|
|
70
|
+
}));
|
|
71
|
+
const skillItems = skillsAllowed ? enabledSkills(skills).map(skillItem) : [];
|
|
72
|
+
const recipeItems = (recipes || []).filter((r) => r && r.enabled !== false && r.name).map(recipeItem);
|
|
73
|
+
return [...own, ...skillItems, ...recipeItems]
|
|
74
|
+
.filter((item) => item.command && item.command.toLowerCase().startsWith(normalized))
|
|
75
|
+
.slice(0, 12);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Is the composer's text a slash command still being typed — the moment to show the menu? */
|
|
79
|
+
export const SLASH_TYPING_RE = /^\/([a-z0-9_-]*(?:\s+[a-z0-9_-]*)?)$/i;
|
|
80
|
+
|
|
81
|
+
/** "/summarize the thread" → the enabled skill whose command is `summarize`, and the rest. */
|
|
82
|
+
export function matchSlashSkill(text, skills = []) {
|
|
83
|
+
const m = /^\/([a-z0-9_-]+)\s*([\s\S]*)$/i.exec(String(text || ''));
|
|
84
|
+
if (!m) return null;
|
|
85
|
+
const skill = enabledSkills(skills).find((s) => String(s.command || '').toLowerCase() === m[1].toLowerCase());
|
|
86
|
+
return skill ? { skill, args: m[2].trim() } : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** "/open_bug Crash on start" → the recipe, and the rest of the line as its input. */
|
|
90
|
+
export function matchSlashRecipe(text, recipes = []) {
|
|
91
|
+
const m = /^\/([a-z0-9_-]+)\s*([\s\S]*)$/i.exec(String(text || ''));
|
|
92
|
+
if (!m) return null;
|
|
93
|
+
const recipe = (recipes || []).find((r) => r && r.enabled !== false && String(r.name || '').toLowerCase() === m[1].toLowerCase());
|
|
94
|
+
return recipe ? { recipe, args: m[2].trim() } : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What the model receives for a recipe command: a request, not a prompt expansion. */
|
|
98
|
+
export function recipeInvocationText(recipe, args = '') {
|
|
99
|
+
const a = String(args || '').trim();
|
|
100
|
+
return `Run the saved recipe "${recipe.name}"${a ? ` with this input: ${a}` : ''}. Use the recipe tool; if a parameter is missing, ask for it.`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function slashCommandInsert(item) {
|
|
104
|
+
return item?.command ? `/${item.command} ` : '/';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* How a skill invocation should READ once it has been sent.
|
|
109
|
+
*
|
|
110
|
+
* The model must receive the whole prompt — that is what a skill IS. The user's own bubble
|
|
111
|
+
* echoing it back is a separate question with a different answer: a screen of instructions
|
|
112
|
+
* they wrote themselves buries the thread the skill was asked to read. So the send keeps
|
|
113
|
+
* the expansion as the message CONTENT (model, exports and memory capture see exactly what
|
|
114
|
+
* they always saw) and carries this beside it, for display only.
|
|
115
|
+
*
|
|
116
|
+
* Null for anything that is not a command, so callers attach it unconditionally and older
|
|
117
|
+
* messages — which have none — keep rendering as they did.
|
|
118
|
+
*/
|
|
119
|
+
export function skillInvocationOf(skill, args = '') {
|
|
120
|
+
if (!skill?.command) return null;
|
|
121
|
+
return {
|
|
122
|
+
command: skill.command,
|
|
123
|
+
args: String(args || '').trim(),
|
|
124
|
+
name: skill.name || '',
|
|
125
|
+
icon: skill.icon || '🎓',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The one line an invocation is worth: `/command args`, exactly as typed. */
|
|
130
|
+
export function skillInvocationLabel(inv) {
|
|
131
|
+
if (!inv?.command) return '';
|
|
132
|
+
return `/${inv.command}${inv.args ? ` ${inv.args}` : ''}`;
|
|
133
|
+
}
|