JohnXu22786
snippet-expander
Steno - dsh plugin: inline #tag shorthand expansion before send (multi-library, aliases, {{variables}}, recursion guards)
- Stars
- 0
- Language
- TypeScript
- Created
- Aug 16, 2026
- Updated
- Aug 16, 2026
Introduction
Steno — inline shorthand expansion plugin for dsh messages
Type #tag and it is automatically replaced with the full text configured in your snippet library before the message is sent. Write high-frequency instructions once, then trigger them with a single short tag.
User types: Please check this code with #review-lens
Actually sent: Please check this code with Please review strictly: first clarify the task goal, then reason step by step;
… (full review checklist) …
Features
- Instant expansion: you write
#tagin a message and themessage.beforeSendhook performs the replacement before sending. - Multiple snippet libraries: any number of YAML library files, loaded in configuration order with earlier files taking precedence; duplicate names across libraries trigger an automatic warning.
- Aliases: a single snippet can be attached to multiple trigger names (
aliases). - Variable placeholders: the body supports
{{name}}and{{name:default}}, with values supplied by the host. - Recursive composition: snippet bodies can reference other
#tags and expand layer by layer; built-in triple protection covers loop detection, a depth limit, and a count limit. - Code protection:
#tags inside fenced code blocks and inline code are never expanded by mistake. - Library management:
steno.save/steno.removetools plus a CLI let you edit and persist snippet libraries on the fly. - Search and preview:
steno.searchretrieves snippets by relevance; the CLIdsh-steno previewshows the fully expanded result. - Self-contained: only one runtime dependency (js-yaml), requires Node ≥ 22.18, no external services.
Quick Start
npm install # install dependencies and build (the prepare hook builds automatically)
npx dsh-steno init # initialize an example library at ~/.dsh/steno/core.yaml (or use --dir)
Edit ~/.dsh/steno/core.yaml and add a snippet:
name: core
entries:
- tag: careful
aliases: [safe]
description: strict mode
body: |
Reason step by step, self-check before output, ask when in doubt, do not speculate.
Write #careful in a message. Snippet libraries support two auto-discovery locations (both can be overridden by explicit configuration):
| Location | Path |
|---|---|
| Project-level | <project dir>/.dsh/steno/*.yaml |
| User-level | ~/.dsh/steno/*.yaml |
When you do not rely on auto-discovery, you can use the environment variable DSH_STENO_LIBRARIES (semicolon-separated path list) or the libraries configuration passed by the host.
Snippet Library Format
A snippet library is a YAML file, a single top-level mapping whose entries is a list of snippets:
name: dev-tools # library name (defaults to file name); used as target for steno.save
description: description # optional
entries:
- tag: review-lens # required: trigger tag
aliases: [review] # optional: string or string array
description: description # optional
body: | # required: string / multi-line string / string array
#focus
Review for correctness, maintainability, performance, and security.
- tag: commit
body: |
{{type:feat}}({{scope:optional}}): {{subject}}
Field rules:
tag: must start with a letter (including Unicode letters such as Chinese), may contain letters/digits/_/-, up to 64 characters; matching is case-insensitive.aliases: a single string or list of strings, same rules astag.body: a string, multi-line string (YAML block scalar), or string array (joined line by line).- Duplicate
tags or invalid fields within one library fail the whole load (the error lists every problem); duplicates across libraries keep the earlier-loaded one and only warn, without blocking.
Note: when a library file is edited via the tools/CLI, the file is re-serialized (format normalized, comments lost). Manual edits are unaffected — you can edit the YAML directly at any time.
Matching Rules
- The trigger is
#+ tag name;#must not be immediately preceded by an ASCII letter/digit/_/-/#or a backslash (avoidingfoo#tagand##tag). - In languages without spaces, like Chinese,
#tagdirectly adjacent to Chinese characters is valid (e.g.,请#专注模式). #must be immediately followed by a letter;#123and markdown headings like# Headingdo not match.#tags inside fenced code blocks (``` and ~~~) and inline code (`x`) are not expanded; unclosed fences/inline code are treated as plain text.\#tagoutputs the literal#tagwithout expansion.- Unknown tags are kept as-is.
Placeholders
| Syntax | Behavior |
|---|---|
{{name}} | Replaced when a variable value is present; otherwise kept verbatim and counted in unresolved |
{{name:default}} | Uses the default value when no variable value is available |
\{{name}} | Outputs the literal {{name}} |
The host can pass variables when invoking hooks/tools (e.g., {"topic": "release plan"}). Each occurrence of a default is resolved independently.
Placeholder regions are opaque: #tags inside {{...}} do not participate in expansion, and defaults are used literally; under the default config (skipCode: true), placeholders inside code regions are not replaced either.
Recursion and Guards
Snippet bodies can reference other snippets (e.g., review-lens referencing focus). Expansion has triple protection; when triggered, the literal text is kept and a warning is emitted:
- Loop detection: the same tag must not appear in its own expansion chain (
a → b → aterminates immediately). - Depth limit: 8 levels by default (
maxDepthconfigurable). - Count limit: a total of 200 expansions per message by default (
maxExpansionsconfigurable), preventing combinatorial explosion.
Configuration
The host can pass a config object when loading the plugin (JSON Schema in config.schema.json):
{
"libraries": ["~/.dsh/steno/core.yaml", ".dsh/steno/project.yaml"],
"defaultLibrary": "core",
"maxDepth": 8,
"maxExpansions": 200,
"skipCode": true,
"keepUnknown": true
}
| Field | Default | Description |
|---|---|---|
libraries | auto-discovered | Array of library file paths, order is priority (earlier wins); supports ~ and ${VAR} |
defaultLibrary | first library | Write target for steno.save when no library is specified |
maxDepth | 8 | Maximum recursion depth for expansion |
maxExpansions | 200 | Upper bound of replacements in a single expansion |
skipCode | true | Whether to skip tags inside code regions |
keepUnknown | true | Whether to keep placeholders verbatim when they have no value and no default |
Library path resolution order: explicit libraries → environment variable DSH_STENO_LIBRARIES → auto-discovery.
Installing in DSH
dsh plugin --profile demo add github:JohnXu22786/snippet-expander
After installation you can use #tag in messages right away. To remove:
dsh plugin --profile demo remove dsh-steno
Host Integration (dsh harness)
The plugin is self-contained; the manifest lives in plugin.json, and the loading contract is as follows:
1. Loading
const mod = await import('./dsh-plugin/snippet-expander/dist/index.js');
const plugin = await mod.createPlugin({
config: { libraries: ['~/.dsh/steno/core.yaml'] },
logger: console, // optional
});
// or use the default export: await mod.default({ config });
The returned instance:
{
id: 'steno',
name: 'Steno',
version: '1.0.0',
hooks: { 'message.beforeSend': handler }, // event interface
tools: [ ToolDef, ... ], // tool interface (5 in total)
config: ResolvedConfig,
warnings: string[], // load warnings
registry, engine, // programmatic access
dispose(): Promise<void>,
}
2. Event Interface (hooks)
| Event | Timing | Behavior |
|---|---|---|
message.beforeSend | Before the user message is sent to the model | Expands #tags in the message, returns the new message and meta.steno (touched tags, warnings, unresolved placeholders) |
const out = await plugin.hooks['message.beforeSend']({
message: 'Please #focus this',
variables: { topic: 'release' }, // optional
});
// out.message is the expanded text; out.meta.steno contains touched / warnings / unresolved
Non-string messages pass through unchanged. The host can attach meta.steno.warnings to the session context so the model knows what happened.
3. Tool Interface (tools)
The host registers the following tools as LLM-callable functions (each tool has name / description / inputSchema / run):
| Tool | Purpose |
|---|---|
steno.list | List all snippets (optionally in a specific library) |
steno.search | Search by relevance over tag/alias/description/body |
steno.expand | Expand arbitrary text and return results and warnings |
steno.save | Add/update a snippet and persist it (tag, body, aliases, description, library) |
steno.remove | Delete a snippet |
4. Skill Interface (skills)
skills/steno.md is a model-facing skill description (name/description + usage) that hosts with skill loading support can load directly.
5. Manifest Fields
plugin.json declares: apiVersion: dsh/plugin@1, runtime.node (entry: dist/index.js, factory: createPlugin, ESM default export), hooks, tools, skills, configSchema. Hosts can use it for capability discovery and validation.
6. dsh Bundle (Cordis entry)
The package also declares dsh.bundle (package.json → cordis.patch.yml), so dsh plugin add github:JohnXu22786/snippet-expander installs it as a Cordis plugin: dist/index.js additionally exports name (dsh-steno), inject = ['tools'] and apply(ctx, config). apply loads the plugin through createPlugin(), registers the 5 tools as dsh ToolDefinitions, and attaches the message.beforeSend hook when the harness emits that event; unload (hot reload) disposes all registrations. The row config is the same StenoConfig documented in Configuration.
CLI
dsh-steno list [--library <name>] [--json]
dsh-steno search <term> [--limit <n>] [--json]
dsh-steno preview <tag> [--var k=v] [--json]
dsh-steno expand <text...> [--var k=v] [--json] # read stdin when text is -
dsh-steno add <tag> <body...> [--library <name>] [--alias <a>] [--description <d>] [--stdin]
dsh-steno remove <tag> [--library <name>]
dsh-steno paths # show config resolution results and warnings
dsh-steno init [--dir <path>]
The CLI shares the same snippet libraries and expansion logic as the plugin and can be used standalone for debugging.
Development
npm run build # compile TypeScript to dist/
npm test # build + full node:test suite
npm run demo # simulate a host loading the plugin and exercise hooks and tools
Source layout:
src/
core/ matcher (tag scanning) / placeholders ({{variables}}) / engine (expansion engine)
store/ library (library file parsing/serialization) / registry (multi-library index and management)
plugin/ hooks (event hooks) / tools (tool definitions)
index.ts plugin entry (createPlugin)
config.ts config parsing
cli.ts CLI entry
test/ node:test tests
libs/ example snippet libraries skills/ skill docs scripts/ demo scripts
Design Trade-offs and Limitations
- Writing library files back via the tools/CLI re-serializes the YAML (comments are lost); manual edits have no such limitation.
listshows the real contents of library files; shadowed (later-loaded duplicate-named) entries are also listed so priority issues are easier to diagnose.- Tag names must start with a letter (including Unicode letters such as Chinese), may contain letters/digits/
_/-, and do not support spaces. - If a code fence spans an expansion boundary (opened in the message, closed in the expanded body), code protection is judged per pre-expansion text segment (see the unclosed-fence handling under "Matching Rules").
- The expansion size of a single message is capped by the hard
maxExpansionslimit; in extreme cases some tags stay literal and produce warnings.
License
This project is released under the MIT License.