@m6d/cortex-cli 1.0.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Scaffold and operate Cortex servers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,5 +33,8 @@
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
36
- }
36
+ },
37
+ "releaseWatchPaths": [
38
+ "internal/contracts"
39
+ ]
37
40
  }
@@ -0,0 +1,207 @@
1
+ /*
2
+ * The grammar and rendering pipeline for user-authored rich text that reaches
3
+ * the LLM (agent prompts, service descriptions). Both sides of the wire share
4
+ * it: the console edits and validates these strings, the console's runtime API
5
+ * pre-renders them (mentions + heading demotion), and `@m6d/cortex-server`
6
+ * applies per-request variable values. Two token grammars live here:
7
+ *
8
+ * - Tool mentions `@[name](uuid)` — the uuid is authoritative; the inline name
9
+ * is only a display cache refreshed on save ({@link normalizeMentions}) and
10
+ * resolved live when text is rendered for the LLM ({@link renderMentions}).
11
+ * - Variables `{{name}}` — validated against a known list at authoring time
12
+ * ({@link analyzeVariables}) and substituted per request on the server
13
+ * ({@link substituteVariables}).
14
+ */
15
+
16
+ /** One mention token, unanchored and flagless — the editor's tokenizer anchors it, the helpers below add `g`. */
17
+ export const MENTION_PATTERN_SOURCE = String.raw`@\[([^\]\n]*)\]\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)`;
18
+
19
+ const MENTION_PATTERN = new RegExp(MENTION_PATTERN_SOURCE, "g");
20
+
21
+ const VARIABLE_PATTERN = /{{\s*([^{}]*?)\s*}}/g;
22
+
23
+ export function extractMentionedToolIds(text: string) {
24
+ const ids: string[] = [];
25
+ for (const match of text.matchAll(MENTION_PATTERN)) {
26
+ const id = match[2];
27
+ if (id && !ids.includes(id)) ids.push(id);
28
+ }
29
+ return ids;
30
+ }
31
+
32
+ /** Replaces each mention token with the tool's current name (falling back to the cached inline name). */
33
+ export function renderMentions(text: string, toolNameById: ReadonlyMap<string, string>) {
34
+ return text.replace(MENTION_PATTERN, (_, cachedName: string, toolId: string) => {
35
+ return toolNameById.get(toolId) ?? cachedName;
36
+ });
37
+ }
38
+
39
+ /** Refreshes stale display names inside stored text, keeping the tokens intact. */
40
+ export function normalizeMentions(text: string, toolNameById: ReadonlyMap<string, string>) {
41
+ return text.replace(MENTION_PATTERN, (token, _cachedName: string, toolId: string) => {
42
+ const name = toolNameById.get(toolId);
43
+ return name === undefined ? token : `@[${name}](${toolId})`;
44
+ });
45
+ }
46
+
47
+ /** Splits the `{{variables}}` referenced in a text into known and unknown ones. */
48
+ export function analyzeVariables<Variable extends string>(
49
+ text: string,
50
+ known: ReadonlyArray<Variable>,
51
+ ) {
52
+ const referenced = new Set(
53
+ Array.from(text.matchAll(VARIABLE_PATTERN), ([, name]) => name?.trim()),
54
+ );
55
+
56
+ return {
57
+ variables: known.filter((variable) => referenced.has(variable)),
58
+ unknownVariables: [...referenced].filter(
59
+ (variable): variable is string =>
60
+ variable !== undefined &&
61
+ !known.some((knownVariable) => knownVariable === variable),
62
+ ),
63
+ };
64
+ }
65
+
66
+ /** Fills `{{variables}}` with per-request values; unknown names stay verbatim. */
67
+ export function substituteVariables(text: string, values: Record<string, string>) {
68
+ return text.replace(/\{\{(\w+)\}\}/g, (match, name: string) => values[name] ?? match);
69
+ }
70
+
71
+ /**
72
+ * Line-by-line fence state, following CommonMark's fence rules: a fence line
73
+ * is indented at most three spaces; an opening backtick fence's info string
74
+ * may not contain backticks; and a fence closes only on the same marker
75
+ * character, with at least the opening run's length, followed by nothing but
76
+ * whitespace — so a `~~~` line, a shorter same-marker run, or a ```` ```js ````
77
+ * info line inside a backtick fence all stay code content.
78
+ */
79
+ function fenceTracker() {
80
+ let fence: { marker: string; length: number } | null = null;
81
+ return (line: string) => {
82
+ const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
83
+ const delimiter = match?.[1];
84
+ const rest = match?.[2] ?? "";
85
+ if (delimiter) {
86
+ const marker = delimiter.charAt(0);
87
+ if (fence) {
88
+ if (
89
+ marker === fence.marker &&
90
+ delimiter.length >= fence.length &&
91
+ rest.trim() === ""
92
+ ) {
93
+ fence = null;
94
+ return "close";
95
+ }
96
+ return "inside";
97
+ }
98
+ if (marker === "`" && rest.includes("`")) return "outside";
99
+ fence = { marker, length: delimiter.length };
100
+ return "open";
101
+ }
102
+ return fence ? "inside" : "outside";
103
+ };
104
+ }
105
+
106
+ /** ATX heading, CommonMark-style: up to three leading spaces, `#` run closed by space or line end. */
107
+ const ATX_HEADING = /^( {0,3})(#{1,6})(?=[ \t]|$)/;
108
+
109
+ /** Setext underline: a run of `=` or `-` alone on its line, up to three leading spaces. */
110
+ const SETEXT_UNDERLINE = /^ {0,3}(=+|-+)[ \t]*$/;
111
+
112
+ /** The skeleton owns `##` and `###`, so user headings start three levels below it. */
113
+ function demotedHashes(level: number) {
114
+ return "#".repeat(Math.min(level + 3, 6));
115
+ }
116
+
117
+ /**
118
+ * Whether a line can be the text of a setext heading — i.e. it is an ordinary
119
+ * paragraph line, not a blank, a fence, another heading, or the start of a
120
+ * different block (quote/list), in which case the underline below it is a
121
+ * thematic break or list content rather than a heading marker.
122
+ */
123
+ function isSetextText(line: string) {
124
+ return (
125
+ line.trim() !== "" &&
126
+ !ATX_HEADING.test(line) &&
127
+ !SETEXT_UNDERLINE.test(line) &&
128
+ !/^ {0,3}(`{3,}|~{3,})/.test(line) &&
129
+ !/^ {0,3}(>|[-*+][ \t]|\d+[.)][ \t])/.test(line)
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Shifts user-authored headings down so they can never collide with the prompt
135
+ * skeleton, which owns `##` (sections) and `###` (card titles): `#` becomes
136
+ * `####`, deeper levels cap at `######`. Setext headings are rewritten as the
137
+ * equivalent demoted ATX heading, since two-level setext cannot express the
138
+ * shift. Fenced code is left alone.
139
+ */
140
+ export function demoteHeadings(text: string) {
141
+ const lineState = fenceTracker();
142
+ const output: string[] = [];
143
+ for (const line of text.split("\n")) {
144
+ if (lineState(line) !== "outside") {
145
+ output.push(line);
146
+ continue;
147
+ }
148
+
149
+ const underline = SETEXT_UNDERLINE.exec(line)?.[1];
150
+ const previous = output[output.length - 1];
151
+ if (underline !== undefined && previous !== undefined && isSetextText(previous)) {
152
+ const level = underline.startsWith("=") ? 1 : 2;
153
+ output[output.length - 1] = `${demotedHashes(level)} ${previous.trim()}`;
154
+ continue;
155
+ }
156
+
157
+ output.push(
158
+ line.replace(ATX_HEADING, (_, indent: string, hashes: string) => {
159
+ return `${indent}${demotedHashes(hashes.length)}`;
160
+ }),
161
+ );
162
+ }
163
+ return output.join("\n");
164
+ }
165
+
166
+ /** The one render step for LLM-bound rich text: resolve mentions, then demote headings. */
167
+ export function renderRichText(text: string, toolNameById: ReadonlyMap<string, string>) {
168
+ return demoteHeadings(renderMentions(text, toolNameById));
169
+ }
170
+
171
+ /**
172
+ * Reduces rich text to plain prose for embeddings: mentions become tool names
173
+ * (cached inline names when no map is given) and markdown syntax is dropped.
174
+ * ponytail: a regex stripper, not a markdown parser — nested emphasis or exotic
175
+ * constructs may leave residue; swap in a real parser if retrieval quality
176
+ * ever shows it.
177
+ */
178
+ export function stripToPlaintext(
179
+ text: string,
180
+ toolNameById: ReadonlyMap<string, string> = new Map(),
181
+ ) {
182
+ const lineState = fenceTracker();
183
+ return renderMentions(text, toolNameById)
184
+ .split("\n")
185
+ .filter((line) => {
186
+ const state = lineState(line);
187
+ return state !== "open" && state !== "close";
188
+ })
189
+ .map((line) =>
190
+ line
191
+ .replace(/^ {0,3}#{1,6}[ \t]+/, "")
192
+ .replace(SETEXT_UNDERLINE, "")
193
+ .replace(/^\s{0,3}(>\s?)+/, "")
194
+ .replace(/^(\s*)([-*+]|\d+[.)])\s+/, "$1")
195
+ .replace(/^\s*([-*_]\s*){3,}$/, ""),
196
+ )
197
+ .join("\n")
198
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
199
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
200
+ .replace(/(\*\*|__)([^*_]+)\1/g, "$2")
201
+ .replace(/(^|\W)[*_]([^*_]+)[*_](?=\W|$)/gm, "$1$2")
202
+ .replace(/~~([^~]+)~~/g, "$1")
203
+ .replace(/`([^`]*)`/g, "$1")
204
+ .replace(/\\([\\*_#[\]()>!`~+.-])/g, "$1")
205
+ .replace(/\n{3,}/g, "\n\n")
206
+ .trim();
207
+ }
@@ -13,7 +13,42 @@ export const AGENT_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
13
13
  /** Runtime contract §5: tool names become `tools.<name>()` sandbox bindings. */
14
14
  export const TOOL_NAME_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
15
15
 
16
- export const PROMPT_VARIABLES = ["userName", "channel", "locale"] as const;
16
+ export const PROMPT_VARIABLES = ["userName", "channel", "locale", "utcTime", "timezone"] as const;
17
+
18
+ /**
19
+ * One entry per prompt variable, driving editor autocomplete, validation, and
20
+ * runtime substitution alike. Adding a variable = extending the tuple above
21
+ * plus one entry here (the compiler enforces the pair); the consumer embedding
22
+ * the agent supplies the value in its session/request context under the same
23
+ * key. Descriptions are plain English on purpose: variables are code-like
24
+ * identifiers shown in an LTR suggestion list, not localized UI copy.
25
+ */
26
+ export const PROMPT_VARIABLE_DEFINITIONS = {
27
+ userName: {
28
+ description: "Display name of the signed-in user",
29
+ example: "Sara",
30
+ },
31
+ channel: {
32
+ description: "Channel the conversation arrived on",
33
+ example: "web",
34
+ },
35
+ locale: {
36
+ description: "Resolved locale of the session",
37
+ example: "ar",
38
+ },
39
+ // The server computes utcTime per request (a consumer-supplied value
40
+ // still wins); timezone has no server default — only the consumer knows
41
+ // the session's zone, so it must arrive via session/request context or
42
+ // the placeholder stays literal.
43
+ utcTime: {
44
+ description: "Current time in UTC (ISO 8601)",
45
+ example: "2026-08-16T09:30:00Z",
46
+ },
47
+ timezone: {
48
+ description: "IANA time zone of the session",
49
+ example: "Asia/Dubai",
50
+ },
51
+ } satisfies Record<(typeof PROMPT_VARIABLES)[number], { description: string; example: string }>;
17
52
 
18
53
  export const RUNTIME_ERROR_KINDS = [
19
54
  "timeout",