@chatpanel/events 0.69.1 → 0.71.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/context-attachments.js +148 -0
- package/index.js +4 -1
- package/package.json +6 -2
- package/skill-vars.js +16 -0
- package/slash-commands.js +133 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// ATTACHED CONTEXT, AS THE MODEL SEES IT — the same on every client.
|
|
2
|
+
//
|
|
3
|
+
// A message carries `attachments`: pages, selections, fetched links, files, images, and the
|
|
4
|
+
// user's own records. This is how they reach a model: text sources folded into the message
|
|
5
|
+
// as <context> blocks, images as image blocks in the provider's wire shape, and — when the
|
|
6
|
+
// turn has tools and the sources are big — a MANIFEST plus a `source` tool instead of the
|
|
7
|
+
// full text, so "hi" on a long page does not pay for the page.
|
|
8
|
+
//
|
|
9
|
+
// Moved out of the extension's providers.js so the desktop's turn assembles attachments
|
|
10
|
+
// exactly the way the panel does. Pure: nothing here fetches or renders.
|
|
11
|
+
|
|
12
|
+
import { makeSourceStore, manifestText, readSource, approxTokens } from './sources-retrieval.js';
|
|
13
|
+
|
|
14
|
+
// Flatten a stored message (with attachments) into the text the model sees.
|
|
15
|
+
// Image attachments are excluded here — they go to the model as image blocks
|
|
16
|
+
// (see toMultimodalMessages), not as text.
|
|
17
|
+
export function renderContent(m) {
|
|
18
|
+
let text = m.content || '';
|
|
19
|
+
const ctx = (m.attachments || []).filter((a) => a.kind !== 'image');
|
|
20
|
+
if (ctx.length) {
|
|
21
|
+
const blocks = ctx
|
|
22
|
+
.map((a) => {
|
|
23
|
+
const head = `[${a.kind || 'context'}] ${a.title || a.url || ''}`.trim();
|
|
24
|
+
return `<context source="${(a.url || a.title || '').replace(/"/g, '')}">\n# ${head}\n${a.text || ''}\n</context>`;
|
|
25
|
+
})
|
|
26
|
+
.join('\n\n');
|
|
27
|
+
text = text ? `${text}\n\n${blocks}` : blocks;
|
|
28
|
+
}
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function toChatMessages(messages) {
|
|
33
|
+
return messages
|
|
34
|
+
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
35
|
+
.map((m) => ({ role: m.role, content: renderContent(m) }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Image attachments on a message: { dataUrl: 'data:<media>;base64,<...>' }.
|
|
39
|
+
export function imageAttachmentsOf(m) {
|
|
40
|
+
return (m.attachments || []).filter((a) => a.kind === 'image' && a.dataUrl);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Like toChatMessages, but emits multimodal content (text + image blocks) for
|
|
44
|
+
// user messages that carry images, in the given provider's wire format. Falls
|
|
45
|
+
// back to plain string content when there are no images. `provider` is
|
|
46
|
+
// 'openai' | 'anthropic'.
|
|
47
|
+
export function toMultimodalMessages(messages, provider) {
|
|
48
|
+
return messages
|
|
49
|
+
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
50
|
+
.map((m) => {
|
|
51
|
+
const text = renderContent(m);
|
|
52
|
+
const imgs = m.role === 'user' ? imageAttachmentsOf(m) : [];
|
|
53
|
+
if (imgs.length === 0) return { role: m.role, content: text };
|
|
54
|
+
if (provider === 'anthropic') {
|
|
55
|
+
const content = [];
|
|
56
|
+
for (const a of imgs) {
|
|
57
|
+
const match = /^data:([^;]+);base64,(.+)$/s.exec(a.dataUrl);
|
|
58
|
+
if (match) content.push({ type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } });
|
|
59
|
+
}
|
|
60
|
+
if (text) content.push({ type: 'text', text });
|
|
61
|
+
return { role: 'user', content: content.length ? content : text };
|
|
62
|
+
}
|
|
63
|
+
// openai (and OpenAI-compatible vision endpoints)
|
|
64
|
+
const content = [];
|
|
65
|
+
if (text) content.push({ type: 'text', text });
|
|
66
|
+
for (const a of imgs) content.push({ type: 'image_url', image_url: { url: a.dataUrl } });
|
|
67
|
+
return { role: 'user', content: content.length ? content : text };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Hand the model a MANIFEST of what is attached, and a tool to read it.
|
|
73
|
+
*
|
|
74
|
+
* Attachments used to be flattened into the first message — every attached tab, in full,
|
|
75
|
+
* before the model had said anything. "hi" on a long page paid for the whole page, and five
|
|
76
|
+
* attached tabs put five documents in the prompt to answer a question about one paragraph of
|
|
77
|
+
* one of them.
|
|
78
|
+
*
|
|
79
|
+
* Two conditions, both necessary:
|
|
80
|
+
* - THE TURN MUST CARRY TOOLS. Deferring content a model cannot then fetch does not save
|
|
81
|
+
* tokens, it deletes the context — the worst possible outcome, and silently.
|
|
82
|
+
* - IT MUST BE WORTH A ROUND TRIP. Below the threshold the extra call costs more than the
|
|
83
|
+
* text it avoids, so small attachments still travel inline.
|
|
84
|
+
*
|
|
85
|
+
* Returns the rewritten messages plus a store, or null when nothing was deferred.
|
|
86
|
+
*/
|
|
87
|
+
export function deferAttachedSources(messages, tools, { minTokens = 700 } = {}) {
|
|
88
|
+
if (!tools?.specs?.length) return null;
|
|
89
|
+
const carried = [];
|
|
90
|
+
for (const m of messages || []) {
|
|
91
|
+
for (const a of m?.attachments || []) {
|
|
92
|
+
if (a?.kind === 'image' || !a?.text) continue;
|
|
93
|
+
carried.push(a);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!carried.length) return null;
|
|
97
|
+
const store = makeSourceStore(carried.map((a) => ({
|
|
98
|
+
kind: a.kind || 'context', title: a.title, url: a.url, text: a.text,
|
|
99
|
+
})));
|
|
100
|
+
if (store.tokens < minTokens) return null;
|
|
101
|
+
// Same index, same id: the manifest and the store must agree or the model asks for
|
|
102
|
+
// something real and is told it does not exist.
|
|
103
|
+
const idFor = new Map(carried.map((a, i) => [a, store.entries[i]?.id]));
|
|
104
|
+
const out = (messages || []).map((m) => {
|
|
105
|
+
if (!m?.attachments?.some((a) => idFor.get(a))) return m;
|
|
106
|
+
return {
|
|
107
|
+
...m,
|
|
108
|
+
attachments: m.attachments.map((a) => {
|
|
109
|
+
const id = idFor.get(a);
|
|
110
|
+
// The stub keeps the title and url — knowing WHAT is attached is what lets the model
|
|
111
|
+
// decide whether to read it, and that part is cheap.
|
|
112
|
+
return id ? { ...a, text: `(not included — read with \`source\`: id ${id}, ~${approxTokens(a.text)} tokens)` } : a;
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
return { messages: out, store };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const SOURCE_TOOL_SPEC = {
|
|
120
|
+
name: 'source',
|
|
121
|
+
description: 'Read an attached source (a page, tab, selection or file the user attached). Their content is NOT in the conversation — read what you need from here.',
|
|
122
|
+
parameters: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
id: { type: 'string', description: 'The source id from the manifest, e.g. page-1.' },
|
|
126
|
+
query: { type: 'string', description: 'What you are looking for. A large source returns the matching sections rather than its first page.' },
|
|
127
|
+
},
|
|
128
|
+
required: ['id'],
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Add `source` to an existing toolset without disturbing what is already there. */
|
|
133
|
+
export function withSourceTool(tools, store) {
|
|
134
|
+
const spec = { ...SOURCE_TOOL_SPEC };
|
|
135
|
+
const system = [
|
|
136
|
+
tools?.system,
|
|
137
|
+
`${manifestText(store)}\n\nTheir content is NOT in this conversation. Call \`source\` with an id — and a query when the source is large — to read what you need.`,
|
|
138
|
+
].filter(Boolean).join('\n\n');
|
|
139
|
+
return {
|
|
140
|
+
...tools,
|
|
141
|
+
specs: [...(tools?.specs || []), spec],
|
|
142
|
+
system,
|
|
143
|
+
systemParts: { ...(tools?.systemParts || {}), source: approxTokens(manifestText(store)) },
|
|
144
|
+
execute: async (name, input, meta) => (name === 'source'
|
|
145
|
+
? JSON.stringify(readSource(store, typeof input === 'string' ? JSON.parse(input || '{}') : (input || {})))
|
|
146
|
+
: tools.execute(name, input, meta)),
|
|
147
|
+
};
|
|
148
|
+
}
|
package/index.js
CHANGED
|
@@ -201,7 +201,8 @@ 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 { SLASH_TYPING_RE, enabledSkills, slashCommandItems, matchSlashSkill, matchSlashRecipe, recipeInvocationText, slashCommandInsert, skillInvocationOf, skillInvocationLabel } from './slash-commands.js';
|
|
205
206
|
export { outlineOf, parseListItem, continueList, indentSelection, toggleWrap, toggleLinePrefix, toggleTask, toggleLink, docStats, selectionStats } from './markdown-authoring.js';
|
|
206
207
|
export {
|
|
207
208
|
MAX_TAG_LENGTH, MAX_TAGS, normalizeTag, normalizeTags, hasTag, addTag, removeTag, toggleTag,
|
|
@@ -284,3 +285,5 @@ export { mcpSharedSystem, mcpInventorySystem, sourceCitationSystem, combineSyste
|
|
|
284
285
|
export { adaptiveToolRetryHint, createAdaptiveToolPolicy, isInvalidToolParametersResult } from './adaptive-tool-policy.js';
|
|
285
286
|
export { getMcpProviders, testMcpServer, resetMcp } from './mcp-manager.js';
|
|
286
287
|
export { WEATHER_TOOL_NAME, WEATHER_TOOL_SYSTEM, weatherToolProvider } from './weather-tool.js';
|
|
288
|
+
// Attached context as the model sees it — <context> blocks, image blocks, deferred sources.
|
|
289
|
+
export { renderContent, toChatMessages, toMultimodalMessages, imageAttachmentsOf, deferAttachedSources, withSourceTool, SOURCE_TOOL_SPEC } from './context-attachments.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.71.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",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"./skill-scan.js": "./skill-scan.js",
|
|
68
68
|
"./skill-sources.js": "./skill-sources.js",
|
|
69
69
|
"./skill-vars.js": "./skill-vars.js",
|
|
70
|
+
"./slash-commands.js": "./slash-commands.js",
|
|
70
71
|
"./sources-retrieval.js": "./sources-retrieval.js",
|
|
71
72
|
"./sources.js": "./sources.js",
|
|
72
73
|
"./store.js": "./store.js",
|
|
@@ -106,7 +107,8 @@
|
|
|
106
107
|
"./adaptive-tool-policy.js": "./adaptive-tool-policy.js",
|
|
107
108
|
"./mcp-client.js": "./mcp-client.js",
|
|
108
109
|
"./mcp-manager.js": "./mcp-manager.js",
|
|
109
|
-
"./weather-tool.js": "./weather-tool.js"
|
|
110
|
+
"./weather-tool.js": "./weather-tool.js",
|
|
111
|
+
"./context-attachments.js": "./context-attachments.js"
|
|
110
112
|
},
|
|
111
113
|
"files": [
|
|
112
114
|
"LICENSE",
|
|
@@ -117,6 +119,7 @@
|
|
|
117
119
|
"capability.js",
|
|
118
120
|
"citations.js",
|
|
119
121
|
"client-prefs.js",
|
|
122
|
+
"context-attachments.js",
|
|
120
123
|
"cowriter-router.js",
|
|
121
124
|
"cowriter.js",
|
|
122
125
|
"curate.js",
|
|
@@ -177,6 +180,7 @@
|
|
|
177
180
|
"skill-scan.js",
|
|
178
181
|
"skill-sources.js",
|
|
179
182
|
"skill-vars.js",
|
|
183
|
+
"slash-commands.js",
|
|
180
184
|
"sources-retrieval.js",
|
|
181
185
|
"sources.js",
|
|
182
186
|
"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
|
+
}
|