@dbx-tools/shared 0.1.18
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/README.md +234 -0
- package/dist/index.client.d.ts +32 -0
- package/dist/index.client.js +32 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/src/api.d.ts +90 -0
- package/dist/src/api.js +165 -0
- package/dist/src/appkit.d.ts +59 -0
- package/dist/src/appkit.js +109 -0
- package/dist/src/common.d.ts +185 -0
- package/dist/src/common.js +277 -0
- package/dist/src/http.d.ts +77 -0
- package/dist/src/http.js +166 -0
- package/dist/src/log.d.ts +47 -0
- package/dist/src/log.js +80 -0
- package/dist/src/net.browser.d.ts +98 -0
- package/dist/src/net.browser.js +146 -0
- package/dist/src/net.d.ts +14 -0
- package/dist/src/net.js +29 -0
- package/dist/src/project.d.ts +33 -0
- package/dist/src/project.js +215 -0
- package/dist/src/string.d.ts +105 -0
- package/dist/src/string.js +220 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/index.client.ts +32 -0
- package/index.ts +26 -0
- package/package.json +54 -0
- package/src/api.ts +222 -0
- package/src/appkit.ts +161 -0
- package/src/common.ts +422 -0
- package/src/http.ts +203 -0
- package/src/log.ts +116 -0
- package/src/net.browser.ts +174 -0
- package/src/net.ts +32 -0
- package/src/project.ts +264 -0
- package/src/string.ts +276 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project introspection helpers shared across AppKit plugins.
|
|
3
|
+
*
|
|
4
|
+
* Resolve a human-friendly project name and parse git remote URLs into
|
|
5
|
+
* repo names. Exposed as `projectUtils.*` from the shared barrel so
|
|
6
|
+
* naming inside this module drops the redundant `project` prefix:
|
|
7
|
+
* `projectUtils.name()` instead of `projectName()`, etc.
|
|
8
|
+
*
|
|
9
|
+
* **Server-only.** Imports `node:fs`, `node:path`, `node:child_process`,
|
|
10
|
+
* and `node:util` at module load. Browser bundles must use
|
|
11
|
+
* `@dbx-tools/shared`'s `index.client.ts` entry point, which
|
|
12
|
+
* skips this module entirely.
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { basename, dirname, resolve } from "node:path";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
const nameByCwd = new Map();
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a human-friendly project name for the repo rooted at `cwd`.
|
|
22
|
+
*
|
|
23
|
+
* Order:
|
|
24
|
+
* 1. `name` from the root `package.json` (via `npm pkg get name` when available,
|
|
25
|
+
* otherwise read the file after locating the root).
|
|
26
|
+
* 2. Repository name from `git remote get-url origin`.
|
|
27
|
+
* 3. Basename of the project root directory.
|
|
28
|
+
*/
|
|
29
|
+
export function name(options) {
|
|
30
|
+
const cwd = resolve(options?.cwd ?? process.cwd());
|
|
31
|
+
let pending = nameByCwd.get(cwd);
|
|
32
|
+
if (pending === undefined) {
|
|
33
|
+
pending = resolveProjectName(cwd);
|
|
34
|
+
nameByCwd.set(cwd, pending);
|
|
35
|
+
}
|
|
36
|
+
return pending;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parse a git remote URL (`https://...`, `git@host:owner/repo.git`, etc.)
|
|
40
|
+
* and return the repo segment, stripping any `.git` suffix. Returns
|
|
41
|
+
* `undefined` for empty or unparsable input.
|
|
42
|
+
*/
|
|
43
|
+
export function parseGitRemote(url) {
|
|
44
|
+
const trimmed = url.trim();
|
|
45
|
+
if (!trimmed) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const scp = /^[^@]+@[^:]+:(.+)$/i.exec(trimmed);
|
|
49
|
+
if (scp) {
|
|
50
|
+
const segment = scp[1];
|
|
51
|
+
return lastPathSegment(segment ?? "");
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const normalized = trimmed.replace(/\.git$/i, "");
|
|
55
|
+
const pathname = new URL(normalized).pathname;
|
|
56
|
+
const segment = pathname.split("/").filter(Boolean).at(-1);
|
|
57
|
+
return segment ? lastPathSegment(segment) : undefined;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function resolveProjectName(cwd) {
|
|
64
|
+
const root = await findProjectRoot(cwd);
|
|
65
|
+
const fromPackage = (await readNameViaNpm(root)) ?? readNameFromPackageJson(root);
|
|
66
|
+
if (fromPackage) {
|
|
67
|
+
return fromPackage;
|
|
68
|
+
}
|
|
69
|
+
const fromGit = await readNameFromGitRemote(root);
|
|
70
|
+
if (fromGit) {
|
|
71
|
+
return fromGit;
|
|
72
|
+
}
|
|
73
|
+
return basename(root);
|
|
74
|
+
}
|
|
75
|
+
async function findProjectRoot(startDir) {
|
|
76
|
+
const cwd = resolve(startDir);
|
|
77
|
+
const fromNpmWorkspace = await npmWorkspaceRoot(cwd);
|
|
78
|
+
if (fromNpmWorkspace && hasPackageJson(fromNpmWorkspace)) {
|
|
79
|
+
return preferWorkspacesRoot(fromNpmWorkspace);
|
|
80
|
+
}
|
|
81
|
+
const fromNpmPrefix = await npmPrefix(cwd);
|
|
82
|
+
if (fromNpmPrefix && hasPackageJson(fromNpmPrefix)) {
|
|
83
|
+
return preferWorkspacesRoot(fromNpmPrefix);
|
|
84
|
+
}
|
|
85
|
+
const walked = walkUpForPackageRoot(cwd);
|
|
86
|
+
if (walked) {
|
|
87
|
+
return walked;
|
|
88
|
+
}
|
|
89
|
+
const fromGit = await gitTopLevel(cwd);
|
|
90
|
+
if (fromGit && hasPackageJson(fromGit)) {
|
|
91
|
+
return fromGit;
|
|
92
|
+
}
|
|
93
|
+
return cwd;
|
|
94
|
+
}
|
|
95
|
+
function preferWorkspacesRoot(startDir) {
|
|
96
|
+
return walkUpForPackageRoot(startDir) ?? startDir;
|
|
97
|
+
}
|
|
98
|
+
function walkUpForPackageRoot(startDir) {
|
|
99
|
+
let dir = resolve(startDir);
|
|
100
|
+
let topmost;
|
|
101
|
+
let workspacesRoot;
|
|
102
|
+
while (true) {
|
|
103
|
+
const pkgPath = resolve(dir, "package.json");
|
|
104
|
+
if (existsSync(pkgPath)) {
|
|
105
|
+
topmost = dir;
|
|
106
|
+
const pkg = readPackageJson(pkgPath);
|
|
107
|
+
if (pkg?.workspaces !== undefined) {
|
|
108
|
+
workspacesRoot = dir;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const parent = dirname(dir);
|
|
112
|
+
if (parent === dir) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
dir = parent;
|
|
116
|
+
}
|
|
117
|
+
return workspacesRoot ?? topmost;
|
|
118
|
+
}
|
|
119
|
+
function hasPackageJson(dir) {
|
|
120
|
+
return existsSync(resolve(dir, "package.json"));
|
|
121
|
+
}
|
|
122
|
+
async function npmPrefix(cwd) {
|
|
123
|
+
return runNpm(["prefix"], cwd);
|
|
124
|
+
}
|
|
125
|
+
async function npmWorkspaceRoot(cwd) {
|
|
126
|
+
const nodeModules = await runNpm(["root", "-w"], cwd);
|
|
127
|
+
if (!nodeModules) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
return dirname(nodeModules);
|
|
131
|
+
}
|
|
132
|
+
async function runNpm(args, cwd) {
|
|
133
|
+
try {
|
|
134
|
+
const { stdout } = await execFileAsync("npm", args, {
|
|
135
|
+
cwd,
|
|
136
|
+
encoding: "utf8",
|
|
137
|
+
});
|
|
138
|
+
const value = stdout.trim();
|
|
139
|
+
return value || undefined;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function readNameViaNpm(root) {
|
|
146
|
+
try {
|
|
147
|
+
const { stdout } = await execFileAsync("npm", ["pkg", "get", "name", "--prefix", root], { encoding: "utf8" });
|
|
148
|
+
return parseNpmPkgGetValue(stdout);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function parseNpmPkgGetValue(stdout) {
|
|
155
|
+
const trimmed = stdout.trim();
|
|
156
|
+
if (!trimmed) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(trimmed);
|
|
161
|
+
if (typeof parsed === "string" && parsed.trim()) {
|
|
162
|
+
return parsed.trim();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return trimmed.replace(/^"|"$/g, "").trim() || undefined;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
function readNameFromPackageJson(root) {
|
|
171
|
+
const pkgPath = resolve(root, "package.json");
|
|
172
|
+
if (!existsSync(pkgPath)) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const pkg = readPackageJson(pkgPath);
|
|
176
|
+
const pkgName = pkg?.name?.trim();
|
|
177
|
+
return pkgName || undefined;
|
|
178
|
+
}
|
|
179
|
+
function readPackageJson(path) {
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function readNameFromGitRemote(root) {
|
|
188
|
+
const url = await gitRemoteOriginUrl(root);
|
|
189
|
+
if (!url) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
return parseGitRemote(url);
|
|
193
|
+
}
|
|
194
|
+
async function gitRemoteOriginUrl(root) {
|
|
195
|
+
try {
|
|
196
|
+
const { stdout } = await execFileAsync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8" });
|
|
197
|
+
return stdout.trim() || undefined;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async function gitTopLevel(cwd) {
|
|
204
|
+
try {
|
|
205
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
206
|
+
return stdout.trim() || undefined;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function lastPathSegment(path) {
|
|
213
|
+
const segment = path.split("/").filter(Boolean).at(-1) ?? path;
|
|
214
|
+
return segment.replace(/\.git$/i, "");
|
|
215
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
type TokenizeOptions = {
|
|
2
|
+
distinct?: boolean;
|
|
3
|
+
lowerCase?: boolean;
|
|
4
|
+
capitalize?: boolean;
|
|
5
|
+
omitUriScheme?: boolean;
|
|
6
|
+
omitEmailDomain?: boolean;
|
|
7
|
+
camelCase?: boolean;
|
|
8
|
+
};
|
|
9
|
+
type KeyOptions = Omit<TokenizeOptions, "lowerCase" | "capitalize"> & {
|
|
10
|
+
maxLength?: number;
|
|
11
|
+
truncateStrategy?: "hash" | "trim" | "empty";
|
|
12
|
+
truncateHashLength?: number;
|
|
13
|
+
};
|
|
14
|
+
type IdentifierOptions = KeyOptions & {
|
|
15
|
+
delimiter?: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function tokenizeWithOptions(options: TokenizeOptions, ...values: unknown[]): Generator<string>;
|
|
18
|
+
export declare function tokenize(...values: unknown[]): Generator<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Join tokenized values with `delimiter`. When the next token would push the
|
|
21
|
+
* result over `maxLength`: `trim` stops adding; `empty` returns `""`; `hash`
|
|
22
|
+
* appends a digest of accepted tokens plus the overflow token if the result
|
|
23
|
+
* still fits, otherwise `""`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function toIdentifierWithOptions(options: IdentifierOptions, ...values: unknown[]): string;
|
|
26
|
+
export declare function toIdentifier(...values: unknown[]): string;
|
|
27
|
+
/**
|
|
28
|
+
* Slugified identifier: same rules as {@link toIdentifierWithOptions} with the
|
|
29
|
+
* delimiter forced to `-`. Accepts {@link KeyOptions} so callers cannot
|
|
30
|
+
* override the delimiter.
|
|
31
|
+
*/
|
|
32
|
+
export declare function toSlugWithOptions(options: KeyOptions, ...values: unknown[]): string;
|
|
33
|
+
export declare function toSlug(...values: unknown[]): string;
|
|
34
|
+
/**
|
|
35
|
+
* Trim `value` and return `null` for non-strings, `undefined`, or
|
|
36
|
+
* strings that are empty after trimming. Lets call sites collapse the
|
|
37
|
+
* common
|
|
38
|
+
*
|
|
39
|
+
* ```ts
|
|
40
|
+
* typeof v === "string" && v.trim() ? v.trim() : null
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* dance into a single helper. Useful for HTTP header / query / form
|
|
44
|
+
* extractors where downstream code wants `string | null` to drive a
|
|
45
|
+
* cheap `??` / `if (x)` cascade.
|
|
46
|
+
*/
|
|
47
|
+
export declare function trimToNull(value: unknown): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Trim the first usable string out of `value`. Returns `null` when
|
|
50
|
+
* `value` is `undefined`, `null`, an empty string, or an array whose
|
|
51
|
+
* first string member is empty. Mirrors how Express / Node header
|
|
52
|
+
* accessors expose single vs. repeated headers - the first
|
|
53
|
+
* non-empty entry wins, everything else is ignored.
|
|
54
|
+
*/
|
|
55
|
+
export declare function firstNonEmpty(value: unknown): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Tagged-template helper that collapses a multi-line indented
|
|
58
|
+
* template literal into a single space-joined string. Lets call
|
|
59
|
+
* sites write Zod `.describe()` blocks, Mastra tool descriptions,
|
|
60
|
+
* and other long prose constants as readable indented paragraphs
|
|
61
|
+
* in source while still emitting clean text the LLM (or any other
|
|
62
|
+
* consumer) doesn't have to mentally re-flow. Interpolated values
|
|
63
|
+
* are stringified verbatim and folded with the surrounding
|
|
64
|
+
* whitespace.
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* toDescription`
|
|
68
|
+
* Ask the Genie space "${alias}" a question.
|
|
69
|
+
* Pass the answer through as-is.
|
|
70
|
+
* `;
|
|
71
|
+
* // -> 'Ask the Genie space "default" a question. Pass the answer through as-is.'
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function toDescription(strings: TemplateStringsArray, ...values: unknown[]): string;
|
|
75
|
+
/**
|
|
76
|
+
* Slugify `value` (using the standard {@link toIdentifierWithOptions}
|
|
77
|
+
* tokenizer + delimiter rules) and **always** suffix a short
|
|
78
|
+
* deterministic hash. Use when you need a stable, slugified id that
|
|
79
|
+
* is guaranteed to be unique across descriptions sharing the same
|
|
80
|
+
* leading tokens (tool ids, cache keys, etc.).
|
|
81
|
+
*
|
|
82
|
+
* Behaviour differs from `toIdentifierWithOptions({ maxLength,
|
|
83
|
+
* truncateStrategy: "hash" })`: that helper only appends a hash when
|
|
84
|
+
* the slug *overflows* `maxLength`. This helper appends a hash
|
|
85
|
+
* unconditionally so the result is collision-resistant even for
|
|
86
|
+
* short inputs. The hash is computed over the raw `value` so two
|
|
87
|
+
* descriptions producing the same slug still get different ids.
|
|
88
|
+
*
|
|
89
|
+
* @param value - Source string (typically a tool/agent description).
|
|
90
|
+
* @param options.delimiter - Token separator (default `"_"`).
|
|
91
|
+
* @param options.slugMaxLength - Cap on the slug portion (the part
|
|
92
|
+
* before the hash). Default 32.
|
|
93
|
+
* @param options.hashLength - Length of the suffix produced by
|
|
94
|
+
* `commonUtils.fnvHash` (Crockford-style base-32 alphabet, max 7
|
|
95
|
+
* chars). Default 6.
|
|
96
|
+
* @param options.fallbackPrefix - Prefix used when the slug is empty
|
|
97
|
+
* (e.g. punctuation-only input). Default `"id"`.
|
|
98
|
+
*/
|
|
99
|
+
export declare function toUniqueSlug(value: string, options?: {
|
|
100
|
+
delimiter?: string;
|
|
101
|
+
slugMaxLength?: number;
|
|
102
|
+
hashLength?: number;
|
|
103
|
+
fallbackPrefix?: string;
|
|
104
|
+
}): string;
|
|
105
|
+
export {};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Direct import (not via the barrel) to avoid a self-import cycle:
|
|
2
|
+
// `index.client.ts` re-exports `* as stringUtils from "./src/string.js"`,
|
|
3
|
+
// so going back through it would close a loop.
|
|
4
|
+
import { fnvHash, fnvHashWithOptions } from "./common.js";
|
|
5
|
+
const TOKENIZE_CAMEL_CASE_REGEXP = /[A-Z]?[a-z]+|[0-9]+|[A-Z]+(?![a-z])/g;
|
|
6
|
+
const TOKENIZE_NON_ALPHANUMERIC_REGEXP = /[a-zA-Z0-9]+/g;
|
|
7
|
+
const URI_REGEXP = /^([a-zA-Z][a-zA-Z0-9+.-]*)?:\/\/([^\s/?#][^\s]*)?$/;
|
|
8
|
+
const EMAIL_REGEXP = /^([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+)@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+)$/;
|
|
9
|
+
const TOKENIZE_DEFAULTS = {
|
|
10
|
+
distinct: false,
|
|
11
|
+
lowerCase: false,
|
|
12
|
+
capitalize: false,
|
|
13
|
+
omitUriScheme: false,
|
|
14
|
+
omitEmailDomain: false,
|
|
15
|
+
camelCase: true,
|
|
16
|
+
};
|
|
17
|
+
const IDENTIFIER_DEFAULTS = {
|
|
18
|
+
...TOKENIZE_DEFAULTS,
|
|
19
|
+
lowerCase: true,
|
|
20
|
+
maxLength: Infinity,
|
|
21
|
+
truncateStrategy: "hash",
|
|
22
|
+
truncateHashLength: 6,
|
|
23
|
+
delimiter: "-",
|
|
24
|
+
};
|
|
25
|
+
export function* tokenizeWithOptions(options, ...values) {
|
|
26
|
+
const opts = { ...TOKENIZE_DEFAULTS, ...options };
|
|
27
|
+
const seen = opts.distinct ? new Set() : undefined;
|
|
28
|
+
const regexp = opts.camelCase
|
|
29
|
+
? TOKENIZE_CAMEL_CASE_REGEXP
|
|
30
|
+
: TOKENIZE_NON_ALPHANUMERIC_REGEXP;
|
|
31
|
+
for (const value of values) {
|
|
32
|
+
if (value == null)
|
|
33
|
+
continue;
|
|
34
|
+
let stringValue = typeof value === "string" ? value : String(value);
|
|
35
|
+
if (!stringValue)
|
|
36
|
+
continue;
|
|
37
|
+
if (opts.omitUriScheme) {
|
|
38
|
+
const match = stringValue.match(URI_REGEXP);
|
|
39
|
+
if (match)
|
|
40
|
+
stringValue = match[2] ?? "";
|
|
41
|
+
}
|
|
42
|
+
if (opts.omitEmailDomain) {
|
|
43
|
+
const match = stringValue.match(EMAIL_REGEXP);
|
|
44
|
+
if (match)
|
|
45
|
+
stringValue = match[1] ?? "";
|
|
46
|
+
}
|
|
47
|
+
if (!stringValue)
|
|
48
|
+
continue;
|
|
49
|
+
for (const tokenMatch of stringValue.matchAll(regexp)) {
|
|
50
|
+
let token = tokenMatch[0];
|
|
51
|
+
if (opts.lowerCase)
|
|
52
|
+
token = token.toLowerCase();
|
|
53
|
+
if (opts.capitalize)
|
|
54
|
+
token = token.charAt(0).toUpperCase() + token.slice(1);
|
|
55
|
+
if (!token || seen?.has(token))
|
|
56
|
+
continue;
|
|
57
|
+
seen?.add(token);
|
|
58
|
+
yield token;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function* tokenize(...values) {
|
|
63
|
+
yield* tokenizeWithOptions({}, ...values);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Join tokenized values with `delimiter`. When the next token would push the
|
|
67
|
+
* result over `maxLength`: `trim` stops adding; `empty` returns `""`; `hash`
|
|
68
|
+
* appends a digest of accepted tokens plus the overflow token if the result
|
|
69
|
+
* still fits, otherwise `""`.
|
|
70
|
+
*/
|
|
71
|
+
export function toIdentifierWithOptions(options, ...values) {
|
|
72
|
+
const opts = {
|
|
73
|
+
...IDENTIFIER_DEFAULTS,
|
|
74
|
+
...options,
|
|
75
|
+
lowerCase: true,
|
|
76
|
+
};
|
|
77
|
+
const tokens = [];
|
|
78
|
+
let currentLength = 0;
|
|
79
|
+
for (const token of tokenizeWithOptions(opts, ...values)) {
|
|
80
|
+
const sepLength = tokens.length > 0 ? opts.delimiter.length : 0;
|
|
81
|
+
const nextLength = currentLength + sepLength + token.length;
|
|
82
|
+
if (nextLength > opts.maxLength) {
|
|
83
|
+
if (opts.truncateStrategy === "empty")
|
|
84
|
+
return "";
|
|
85
|
+
if (opts.truncateStrategy === "trim")
|
|
86
|
+
break;
|
|
87
|
+
const hash = digestTokens(opts.truncateHashLength, tokens, token);
|
|
88
|
+
if (currentLength + sepLength + hash.length <= opts.maxLength) {
|
|
89
|
+
return tokens.length > 0
|
|
90
|
+
? tokens.join(opts.delimiter) + opts.delimiter + hash
|
|
91
|
+
: hash;
|
|
92
|
+
}
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
tokens.push(token);
|
|
96
|
+
currentLength = nextLength;
|
|
97
|
+
}
|
|
98
|
+
return tokens.join(opts.delimiter);
|
|
99
|
+
}
|
|
100
|
+
export function toIdentifier(...values) {
|
|
101
|
+
return toIdentifierWithOptions({}, ...values);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Slugified identifier: same rules as {@link toIdentifierWithOptions} with the
|
|
105
|
+
* delimiter forced to `-`. Accepts {@link KeyOptions} so callers cannot
|
|
106
|
+
* override the delimiter.
|
|
107
|
+
*/
|
|
108
|
+
export function toSlugWithOptions(options, ...values) {
|
|
109
|
+
return toIdentifierWithOptions({ ...options, delimiter: "-" }, ...values);
|
|
110
|
+
}
|
|
111
|
+
export function toSlug(...values) {
|
|
112
|
+
return toSlugWithOptions({}, ...values);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Trim `value` and return `null` for non-strings, `undefined`, or
|
|
116
|
+
* strings that are empty after trimming. Lets call sites collapse the
|
|
117
|
+
* common
|
|
118
|
+
*
|
|
119
|
+
* ```ts
|
|
120
|
+
* typeof v === "string" && v.trim() ? v.trim() : null
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* dance into a single helper. Useful for HTTP header / query / form
|
|
124
|
+
* extractors where downstream code wants `string | null` to drive a
|
|
125
|
+
* cheap `??` / `if (x)` cascade.
|
|
126
|
+
*/
|
|
127
|
+
export function trimToNull(value) {
|
|
128
|
+
if (typeof value !== "string")
|
|
129
|
+
return null;
|
|
130
|
+
const trimmed = value.trim();
|
|
131
|
+
return trimmed ? trimmed : null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Trim the first usable string out of `value`. Returns `null` when
|
|
135
|
+
* `value` is `undefined`, `null`, an empty string, or an array whose
|
|
136
|
+
* first string member is empty. Mirrors how Express / Node header
|
|
137
|
+
* accessors expose single vs. repeated headers - the first
|
|
138
|
+
* non-empty entry wins, everything else is ignored.
|
|
139
|
+
*/
|
|
140
|
+
export function firstNonEmpty(value) {
|
|
141
|
+
if (Array.isArray(value)) {
|
|
142
|
+
for (const item of value) {
|
|
143
|
+
const trimmed = trimToNull(item);
|
|
144
|
+
if (trimmed)
|
|
145
|
+
return trimmed;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return trimToNull(value);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Tagged-template helper that collapses a multi-line indented
|
|
153
|
+
* template literal into a single space-joined string. Lets call
|
|
154
|
+
* sites write Zod `.describe()` blocks, Mastra tool descriptions,
|
|
155
|
+
* and other long prose constants as readable indented paragraphs
|
|
156
|
+
* in source while still emitting clean text the LLM (or any other
|
|
157
|
+
* consumer) doesn't have to mentally re-flow. Interpolated values
|
|
158
|
+
* are stringified verbatim and folded with the surrounding
|
|
159
|
+
* whitespace.
|
|
160
|
+
*
|
|
161
|
+
* ```ts
|
|
162
|
+
* toDescription`
|
|
163
|
+
* Ask the Genie space "${alias}" a question.
|
|
164
|
+
* Pass the answer through as-is.
|
|
165
|
+
* `;
|
|
166
|
+
* // -> 'Ask the Genie space "default" a question. Pass the answer through as-is.'
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
export function toDescription(strings, ...values) {
|
|
170
|
+
let out = "";
|
|
171
|
+
for (let i = 0; i < strings.length; i += 1) {
|
|
172
|
+
out += strings[i];
|
|
173
|
+
if (i < values.length)
|
|
174
|
+
out += String(values[i]);
|
|
175
|
+
}
|
|
176
|
+
return out.replace(/\s+/g, " ").trim();
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Slugify `value` (using the standard {@link toIdentifierWithOptions}
|
|
180
|
+
* tokenizer + delimiter rules) and **always** suffix a short
|
|
181
|
+
* deterministic hash. Use when you need a stable, slugified id that
|
|
182
|
+
* is guaranteed to be unique across descriptions sharing the same
|
|
183
|
+
* leading tokens (tool ids, cache keys, etc.).
|
|
184
|
+
*
|
|
185
|
+
* Behaviour differs from `toIdentifierWithOptions({ maxLength,
|
|
186
|
+
* truncateStrategy: "hash" })`: that helper only appends a hash when
|
|
187
|
+
* the slug *overflows* `maxLength`. This helper appends a hash
|
|
188
|
+
* unconditionally so the result is collision-resistant even for
|
|
189
|
+
* short inputs. The hash is computed over the raw `value` so two
|
|
190
|
+
* descriptions producing the same slug still get different ids.
|
|
191
|
+
*
|
|
192
|
+
* @param value - Source string (typically a tool/agent description).
|
|
193
|
+
* @param options.delimiter - Token separator (default `"_"`).
|
|
194
|
+
* @param options.slugMaxLength - Cap on the slug portion (the part
|
|
195
|
+
* before the hash). Default 32.
|
|
196
|
+
* @param options.hashLength - Length of the suffix produced by
|
|
197
|
+
* `commonUtils.fnvHash` (Crockford-style base-32 alphabet, max 7
|
|
198
|
+
* chars). Default 6.
|
|
199
|
+
* @param options.fallbackPrefix - Prefix used when the slug is empty
|
|
200
|
+
* (e.g. punctuation-only input). Default `"id"`.
|
|
201
|
+
*/
|
|
202
|
+
export function toUniqueSlug(value, options = {}) {
|
|
203
|
+
const delimiter = options.delimiter ?? "_";
|
|
204
|
+
const slugMaxLength = options.slugMaxLength ?? 32;
|
|
205
|
+
const hashLength = options.hashLength ?? 6;
|
|
206
|
+
const fallbackPrefix = options.fallbackPrefix ?? "id";
|
|
207
|
+
const slug = toIdentifierWithOptions({ delimiter, maxLength: slugMaxLength, truncateStrategy: "trim" }, value);
|
|
208
|
+
const suffix = fnvHashWithOptions({ length: hashLength }, value);
|
|
209
|
+
return slug
|
|
210
|
+
? `${slug}${delimiter}${suffix}`
|
|
211
|
+
: `${fallbackPrefix}${delimiter}${suffix}`;
|
|
212
|
+
}
|
|
213
|
+
function digestTokens(length, parts, extra) {
|
|
214
|
+
let combined = "";
|
|
215
|
+
for (const part of parts)
|
|
216
|
+
combined += part + "\0";
|
|
217
|
+
if (extra !== undefined)
|
|
218
|
+
combined += extra + "\0";
|
|
219
|
+
return fnvHashWithOptions({ length }, combined);
|
|
220
|
+
}
|