mcp-memory-bucket 0.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/LICENSE +21 -0
- package/README.md +76 -0
- package/bin/mcp-memory-bucket.js +2 -0
- package/dist/client/assets/index-CxhInmjj.js +150 -0
- package/dist/client/index.html +16 -0
- package/dist/src/config.js +23 -0
- package/dist/src/memory/repository.js +99 -0
- package/dist/src/memory/tools.js +97 -0
- package/dist/src/server.js +86 -0
- package/dist/src/shared/relocate-tool.js +32 -0
- package/dist/src/shared/relocate.js +106 -0
- package/dist/src/skills/builtin/memory-bucket-authoring/SKILL.md +187 -0
- package/dist/src/skills/repository.js +144 -0
- package/dist/src/skills/tools.js +82 -0
- package/dist/src/store/db.js +74 -0
- package/dist/src/store/markdown-file.js +19 -0
- package/dist/src/store/safe-path.js +11 -0
- package/dist/src/store/skill-name.js +11 -0
- package/dist/src/store/slug.js +7 -0
- package/dist/src/store/sync.js +144 -0
- package/dist/src/types.js +4 -0
- package/dist/src/web/routes.js +204 -0
- package/dist/src/web/ui-tool.js +6 -0
- package/package.json +54 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isValidSkillName } from '../store/skill-name.js';
|
|
4
|
+
const TICKET_PATTERN = /([A-Z]{2,10}-\d+)/i;
|
|
5
|
+
/**
|
|
6
|
+
* Infers key/doc_type/description from a filename following the existing
|
|
7
|
+
* plan/spec naming convention, e.g.
|
|
8
|
+
* "2026-08-12-pde-433-partner-configuration-management-v3.md" under a
|
|
9
|
+
* "plans/" folder → { key: "PDE-433", doc_type: "plan", description: "partner configuration management v3" }.
|
|
10
|
+
* Returns null on a weak/ambiguous match — caller must not guess or partially move.
|
|
11
|
+
*/
|
|
12
|
+
export function inferMemoryFrontmatter(filePath) {
|
|
13
|
+
const base = path.basename(filePath, path.extname(filePath));
|
|
14
|
+
const ticketMatch = base.match(TICKET_PATTERN);
|
|
15
|
+
if (!ticketMatch)
|
|
16
|
+
return null;
|
|
17
|
+
const key = ticketMatch[1].toUpperCase();
|
|
18
|
+
const parentDir = path.basename(path.dirname(filePath)).toLowerCase();
|
|
19
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
20
|
+
let docType = 'other';
|
|
21
|
+
if (ext === '.sql')
|
|
22
|
+
docType = 'sql';
|
|
23
|
+
else if (parentDir.includes('plan'))
|
|
24
|
+
docType = 'plan';
|
|
25
|
+
else if (parentDir.includes('spec') || parentDir.includes('design'))
|
|
26
|
+
docType = 'spec';
|
|
27
|
+
else if (parentDir.includes('test'))
|
|
28
|
+
docType = 'testing-todo';
|
|
29
|
+
// Remainder of the slug after the date prefix and ticket token, de-hyphenated.
|
|
30
|
+
const withoutDate = base.replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
|
31
|
+
const withoutTicket = withoutDate.replace(new RegExp(ticketMatch[1], 'i'), '');
|
|
32
|
+
const description = withoutTicket
|
|
33
|
+
.replace(/^-+|-+$/g, '')
|
|
34
|
+
.replace(/-+/g, ' ')
|
|
35
|
+
.trim();
|
|
36
|
+
if (!description)
|
|
37
|
+
return null; // nothing left to distinguish this doc — ambiguous
|
|
38
|
+
return { key, key_type: 'ticket', doc_type: docType, description };
|
|
39
|
+
}
|
|
40
|
+
export function relocate(opts, skillRepo, memoryRepo) {
|
|
41
|
+
if (!fs.existsSync(opts.path) || !fs.statSync(opts.path).isFile()) {
|
|
42
|
+
return { moved: false, reason: `file not found or not readable: ${opts.path}` };
|
|
43
|
+
}
|
|
44
|
+
const body = fs.readFileSync(opts.path, 'utf-8').trim();
|
|
45
|
+
if (opts.target === 'skill') {
|
|
46
|
+
const name = opts.overrides?.name ?? slugFromFilename(opts.path);
|
|
47
|
+
const description = opts.overrides?.description;
|
|
48
|
+
// The spec requires description to state what the skill does AND when to
|
|
49
|
+
// use it — that can't be responsibly guessed from a filename, unlike a
|
|
50
|
+
// slug-derived name. Require it explicitly rather than writing a filler
|
|
51
|
+
// description that would never trigger discovery correctly.
|
|
52
|
+
if (!name || !isValidSkillName(name)) {
|
|
53
|
+
return { moved: false, reason: `could not infer a valid skill name from filename — provide overrides.name (lowercase, hyphenated, <=64 chars)` };
|
|
54
|
+
}
|
|
55
|
+
if (!description) {
|
|
56
|
+
return { moved: false, reason: 'skill description cannot be inferred from a filename — provide overrides.description (what it does and when to use it)' };
|
|
57
|
+
}
|
|
58
|
+
const doc = skillRepo.create({
|
|
59
|
+
name,
|
|
60
|
+
description,
|
|
61
|
+
owner: null,
|
|
62
|
+
status: opts.overrides?.status ?? 'unreviewed',
|
|
63
|
+
tags: opts.overrides?.tags ?? [],
|
|
64
|
+
trigger_phrases: [],
|
|
65
|
+
extends: null,
|
|
66
|
+
}, body, opts.overrides?.folder);
|
|
67
|
+
if (!opts.keep_original)
|
|
68
|
+
fs.unlinkSync(opts.path);
|
|
69
|
+
return { moved: true, id: doc.name, target: 'skill' };
|
|
70
|
+
}
|
|
71
|
+
// target === 'memory'
|
|
72
|
+
const inferred = inferMemoryFrontmatter(opts.path);
|
|
73
|
+
const key = opts.overrides?.key ?? inferred?.key;
|
|
74
|
+
const description = opts.overrides?.description ?? inferred?.description;
|
|
75
|
+
const docType = opts.overrides?.doc_type ?? inferred?.doc_type ?? 'other';
|
|
76
|
+
const keyType = opts.overrides?.key_type ?? inferred?.key_type ?? 'freeform';
|
|
77
|
+
if (!key || !description) {
|
|
78
|
+
return {
|
|
79
|
+
moved: false,
|
|
80
|
+
reason: `filename does not clearly match the ticket-key naming pattern — ask the user for an explicit key and description, then retry with overrides.key/overrides.description`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const already = memoryRepo.getByKey(key, docType).find((d) => d.description === description);
|
|
84
|
+
if (already) {
|
|
85
|
+
return { moved: false, reason: `already relocated as memory doc "${already.id}"`, id: already.id, target: 'memory' };
|
|
86
|
+
}
|
|
87
|
+
const doc = memoryRepo.create({
|
|
88
|
+
key,
|
|
89
|
+
key_type: keyType,
|
|
90
|
+
doc_type: docType,
|
|
91
|
+
description,
|
|
92
|
+
body,
|
|
93
|
+
tags: opts.overrides?.tags,
|
|
94
|
+
folder: opts.overrides?.folder,
|
|
95
|
+
});
|
|
96
|
+
if (!opts.keep_original)
|
|
97
|
+
fs.unlinkSync(opts.path);
|
|
98
|
+
return { moved: true, id: doc.id, target: 'memory' };
|
|
99
|
+
}
|
|
100
|
+
function slugFromFilename(filePath) {
|
|
101
|
+
return path
|
|
102
|
+
.basename(filePath, path.extname(filePath))
|
|
103
|
+
.toLowerCase()
|
|
104
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
105
|
+
.replace(/^-+|-+$/g, '');
|
|
106
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "memory-bucket-authoring"
|
|
3
|
+
description: "Explains how to author and use skills and memory docs through the memory-bucket MCP server's skill_*/memory_*/relocate tools — the skill.md frontmatter schema (agentskills.io standard), the memory frontmatter schema, and when to use each. Use whenever a memory-bucket MCP server is connected and you need to create, update, or decide between a skill and a memory doc, or when the user asks to save a plan/spec/session summary or to record a reusable pattern."
|
|
4
|
+
tags: ["memory-bucket", "meta", "authoring"]
|
|
5
|
+
trigger_phrases: ["save this as a skill", "save this to memory", "remember this plan", "create a skill", "memory-bucket"]
|
|
6
|
+
metadata:
|
|
7
|
+
owner: "company"
|
|
8
|
+
status: "stable"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Two namespaces, two different jobs
|
|
12
|
+
|
|
13
|
+
If a `memory-bucket` MCP server is connected, it exposes two tool
|
|
14
|
+
families backed by markdown+frontmatter files:
|
|
15
|
+
|
|
16
|
+
- **`skill_*`** — reusable, evergreen coding patterns. Found by keyword
|
|
17
|
+
search. Use for: a convention, a component pattern, an idiom you'd want
|
|
18
|
+
to reuse across many unrelated pieces of work.
|
|
19
|
+
- **`memory_*`** — point-in-time working context (plans, specs, SQL,
|
|
20
|
+
testing notes, session summaries) attached to a key (usually a ticket
|
|
21
|
+
ID, sometimes a free-form name like "Spot Chart Design"). Found by
|
|
22
|
+
exact key lookup, not search. Use for: anything tied to one specific
|
|
23
|
+
piece of work that won't be relevant once that work ships.
|
|
24
|
+
|
|
25
|
+
If you're unsure which one applies: would this be useful on a totally
|
|
26
|
+
different ticket next month with no connection to today's task? Skill.
|
|
27
|
+
Is it specific to what's happening right now? Memory.
|
|
28
|
+
|
|
29
|
+
## Authoring a skill
|
|
30
|
+
|
|
31
|
+
A skill is a **folder** containing a `SKILL.md` file, per the
|
|
32
|
+
[agentskills.io](https://agentskills.io) open standard — the same format
|
|
33
|
+
Claude Code, Cursor, and other agents read directly off disk, independent
|
|
34
|
+
of this MCP server. `skill_create` writes exactly this shape for you; you
|
|
35
|
+
don't need to construct the file yourself.
|
|
36
|
+
|
|
37
|
+
Required frontmatter:
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
---
|
|
41
|
+
name: "lit-dropdown-component" # 1-64 chars, lowercase letters/numbers/hyphens only,
|
|
42
|
+
# no leading/trailing/consecutive hyphens.
|
|
43
|
+
# This becomes the containing folder's name.
|
|
44
|
+
description: "Builds a dropdown component in Lit with keyboard navigation and ARIA roles. Use when the user asks for a dropdown, select, or combobox component in a Lit-based frontend."
|
|
45
|
+
---
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**`description` is the single most important field.** It's the only
|
|
49
|
+
thing loaded into context at discovery time (along with `name`) — an
|
|
50
|
+
agent scans it to decide whether the skill is relevant *before* ever
|
|
51
|
+
reading the body. Write it to cover both what the skill does and when to
|
|
52
|
+
use it, in the same sentence or two. "Helps with dropdowns" is too vague
|
|
53
|
+
to trigger reliably; "Builds a dropdown component in Lit with keyboard
|
|
54
|
+
navigation... use when the user asks for a dropdown, select, or combobox
|
|
55
|
+
component" gives an agent something to pattern-match against.
|
|
56
|
+
|
|
57
|
+
**Always write the description in third person** — "Processes X" or
|
|
58
|
+
"Builds Y", never "I can help you..." or "You can use this to...". It's
|
|
59
|
+
injected into the system prompt alongside every other skill's
|
|
60
|
+
description, and an inconsistent point of view degrades discovery.
|
|
61
|
+
|
|
62
|
+
**Naming**: prefer gerund form (`processing-pdfs`, `writing-tests`) or a
|
|
63
|
+
plain noun phrase (`pdf-processing`, `lit-dropdown-component`) — both are
|
|
64
|
+
fine, pick whichever reads more naturally for the pattern. Avoid vague or
|
|
65
|
+
generic names a skill list can't be scanned by: `helper`, `utils`,
|
|
66
|
+
`tools`, `data`, `files`.
|
|
67
|
+
|
|
68
|
+
Optional frontmatter this project also supports:
|
|
69
|
+
|
|
70
|
+
- `license`, `compatibility` — standard fields, rarely needed.
|
|
71
|
+
- `tags`, `trigger_phrases` — arrays of extra keywords `skill_list`'s
|
|
72
|
+
keyword search matches against, beyond `description` itself.
|
|
73
|
+
- `owner`, `status` (`stable`/`beta`/`unreviewed`), `extends` — stored
|
|
74
|
+
under `metadata` in the frontmatter (a string-keyed map the standard
|
|
75
|
+
reserves for exactly this kind of client-specific extension). `status`
|
|
76
|
+
defaults to `unreviewed` if omitted, so low-trust content doesn't read
|
|
77
|
+
as equivalent to a reviewed pattern.
|
|
78
|
+
|
|
79
|
+
Call `skill_create(name, description, body, ...)`. Pass `folder` to place
|
|
80
|
+
it under a subdirectory (e.g. `folder: "frontend"`) if the skill source
|
|
81
|
+
tree is organized that way — check `skill_list()` or ask the user if
|
|
82
|
+
you're not sure of the convention in this repo.
|
|
83
|
+
|
|
84
|
+
### Writing the body
|
|
85
|
+
|
|
86
|
+
The agent reading a skill is already capable — don't explain things it
|
|
87
|
+
already knows (what a PDF is, how a for-loop works). Before adding a
|
|
88
|
+
sentence, ask "does this justify its token cost?" A concise 3-line code
|
|
89
|
+
snippet beats a paragraph of preamble around it.
|
|
90
|
+
|
|
91
|
+
Match how prescriptive you are to how fragile the task is:
|
|
92
|
+
|
|
93
|
+
- **High freedom** (numbered steps, heuristics) — when multiple valid
|
|
94
|
+
approaches exist and judgment matters, e.g. "review this code for
|
|
95
|
+
bugs."
|
|
96
|
+
- **Medium freedom** (a template or parameterized snippet) — when a
|
|
97
|
+
preferred pattern exists but some variation is fine.
|
|
98
|
+
- **Low freedom** (an exact command, "do not modify this") — when the
|
|
99
|
+
operation is fragile or must run in an exact sequence, e.g. a
|
|
100
|
+
migration script.
|
|
101
|
+
|
|
102
|
+
Other things that reliably improve a skill:
|
|
103
|
+
|
|
104
|
+
- Use one term for one concept throughout (always "field", never a mix
|
|
105
|
+
of "field"/"box"/"control") — inconsistent vocabulary makes the
|
|
106
|
+
instructions harder to follow.
|
|
107
|
+
- Avoid time-sensitive claims ("before/after March 2026, use X") since
|
|
108
|
+
they silently rot; if a pattern is genuinely deprecated, name the
|
|
109
|
+
current approach first and fold the old one into a clearly-labeled
|
|
110
|
+
"legacy" aside instead of a date-gated branch.
|
|
111
|
+
- Don't enumerate every possible library/approach — give one good
|
|
112
|
+
default plus, if truly needed, a named escape hatch for the exception
|
|
113
|
+
case. "You can use pypdf, or pdfplumber, or PyMuPDF, or..." is worse
|
|
114
|
+
than just picking one.
|
|
115
|
+
- If the body instructs calling another MCP server's tool, use the fully
|
|
116
|
+
qualified `ServerName:tool_name` form so it isn't ambiguous which
|
|
117
|
+
server owns it.
|
|
118
|
+
|
|
119
|
+
### Keeping SKILL.md small (progressive disclosure)
|
|
120
|
+
|
|
121
|
+
Keep the `SKILL.md` body itself under ~500 lines; it's loaded in full
|
|
122
|
+
once the skill is activated, so move anything long (detailed reference
|
|
123
|
+
tables, big code samples) into files the body links to, if the host
|
|
124
|
+
environment supports bundling extra files alongside `SKILL.md`.
|
|
125
|
+
|
|
126
|
+
When you do split content out:
|
|
127
|
+
|
|
128
|
+
- **Link only one level deep from `SKILL.md` itself.** A reference file
|
|
129
|
+
that links to another reference file risks a shallow partial read (the
|
|
130
|
+
agent may `head` it instead of reading in full) and losing information.
|
|
131
|
+
Put every reference file's link directly in `SKILL.md`, even if that
|
|
132
|
+
means `SKILL.md` links to several files.
|
|
133
|
+
- If a linked-out reference file runs past ~100 lines, put a short table
|
|
134
|
+
of contents at its top so a partial read still reveals what's there.
|
|
135
|
+
|
|
136
|
+
## Authoring a memory doc
|
|
137
|
+
|
|
138
|
+
Every memory doc needs a `key` (the lookup handle) and a `description`
|
|
139
|
+
(what distinguishes it from other docs sharing that key):
|
|
140
|
+
|
|
141
|
+
```yaml
|
|
142
|
+
---
|
|
143
|
+
key: "RMXS-14" # normalized on write: uppercase, hyphenated
|
|
144
|
+
key_type: "ticket" # "ticket" for a real ticket ID, "freeform" for names like "Spot Chart Design"
|
|
145
|
+
doc_type: "plan" # plan | spec | sql | testing-todo | discovery | session-summary | other
|
|
146
|
+
description: "Bulk edit plan for product boost"
|
|
147
|
+
---
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Call `memory_create(key, key_type, doc_type, description, body, ...)`.
|
|
151
|
+
One key commonly accumulates several docs over time — a plan, then a
|
|
152
|
+
spec, then SQL from a debugging session, then a session summary — all
|
|
153
|
+
retrievable together via `memory_get(key)`, or narrowed with
|
|
154
|
+
`memory_get(key, doc_type)`.
|
|
155
|
+
|
|
156
|
+
**Never infer the key from environment state** (branch name, current
|
|
157
|
+
directory, etc.) — always get it from what the user actually said in
|
|
158
|
+
conversation, or ask if it's genuinely unclear.
|
|
159
|
+
|
|
160
|
+
### Saving a session
|
|
161
|
+
|
|
162
|
+
If the user asks to save the current conversation/session as memory, use
|
|
163
|
+
`memory_save_session(summary, key, description, tags?)` — pass a
|
|
164
|
+
**summary**, not a raw transcript. If `key` or `description` weren't
|
|
165
|
+
given, ask for both before calling it; don't guess a key from context.
|
|
166
|
+
|
|
167
|
+
## relocate: pulling in an existing file
|
|
168
|
+
|
|
169
|
+
If there's already a local markdown file that should become a skill or
|
|
170
|
+
memory doc — the user says something like "save this file as memory" or
|
|
171
|
+
"turn this into a skill" — use `relocate(path, target, overrides?)`
|
|
172
|
+
instead of reading the file yourself and calling `*_create`. It infers
|
|
173
|
+
what it can from the filename and moves (not copies, by default) the
|
|
174
|
+
file into place.
|
|
175
|
+
|
|
176
|
+
- For `target: "memory"`, it tries to infer `key`/`doc_type`/`description`
|
|
177
|
+
from filenames like `2026-08-12-pde-433-partner-configuration-v3.md`
|
|
178
|
+
(date + ticket + slug), and does **nothing** — no partial move, no
|
|
179
|
+
guess — if the filename doesn't clearly match. If that happens, ask the
|
|
180
|
+
user for the key and description, then retry with
|
|
181
|
+
`overrides.key`/`overrides.description`.
|
|
182
|
+
- For `target: "skill"`, the name can usually be inferred from the
|
|
183
|
+
filename, but `overrides.description` is required — a good description
|
|
184
|
+
needs real content a filename can't provide, so don't try to invent one
|
|
185
|
+
yourself either; ask the user what the skill does and when to use it.
|
|
186
|
+
- Safe to re-run on the same file/target: if a matching doc already
|
|
187
|
+
exists, it reports that instead of duplicating.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { writeMarkdownFile } from '../store/markdown-file.js';
|
|
4
|
+
import { assertValidSkillName } from '../store/skill-name.js';
|
|
5
|
+
import { resolveWithinBase } from '../store/safe-path.js';
|
|
6
|
+
import { upsertFile, removeFile, skillSyncSpec } from '../store/sync.js';
|
|
7
|
+
function rowToDoc(row) {
|
|
8
|
+
return {
|
|
9
|
+
name: row.id,
|
|
10
|
+
description: row.description,
|
|
11
|
+
tags: JSON.parse(row.tags),
|
|
12
|
+
trigger_phrases: JSON.parse(row.trigger_phrases),
|
|
13
|
+
metadata: { owner: row.owner, status: row.status, extends: row.extends },
|
|
14
|
+
source_path: row.source_path,
|
|
15
|
+
body: row.body,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export class SkillRepository {
|
|
19
|
+
db;
|
|
20
|
+
sourceDir;
|
|
21
|
+
syncSpec;
|
|
22
|
+
constructor(db, sourceDir) {
|
|
23
|
+
this.db = db;
|
|
24
|
+
this.sourceDir = sourceDir;
|
|
25
|
+
this.syncSpec = skillSyncSpec([sourceDir]);
|
|
26
|
+
}
|
|
27
|
+
list(query) {
|
|
28
|
+
const rows = this.db
|
|
29
|
+
.prepare(`SELECT id, description, owner, status, tags, trigger_phrases FROM skills`)
|
|
30
|
+
.all();
|
|
31
|
+
const needle = query?.trim().toLowerCase();
|
|
32
|
+
const items = rows.map((r) => ({
|
|
33
|
+
name: r.id,
|
|
34
|
+
description: r.description,
|
|
35
|
+
owner: r.owner,
|
|
36
|
+
status: r.status,
|
|
37
|
+
tags: JSON.parse(r.tags),
|
|
38
|
+
triggerPhrases: JSON.parse(r.trigger_phrases),
|
|
39
|
+
}));
|
|
40
|
+
const filtered = needle
|
|
41
|
+
? items.filter((item) => item.description.toLowerCase().includes(needle) ||
|
|
42
|
+
item.tags.some((t) => t.toLowerCase().includes(needle)) ||
|
|
43
|
+
item.triggerPhrases.some((t) => t.toLowerCase().includes(needle)))
|
|
44
|
+
: items;
|
|
45
|
+
return filtered.map(({ triggerPhrases: _tp, ...rest }) => rest);
|
|
46
|
+
}
|
|
47
|
+
get(name) {
|
|
48
|
+
const row = this.db.prepare(`SELECT * FROM skills WHERE id = ?`).get(name);
|
|
49
|
+
return row ? rowToDoc(row) : null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Creates <sourceDir>/[folder/]<name>/SKILL.md — folder-per-skill, per the
|
|
53
|
+
* agentskills.io spec (`name` must equal the containing folder's name).
|
|
54
|
+
*/
|
|
55
|
+
create(frontmatter, body, folder) {
|
|
56
|
+
assertValidSkillName(frontmatter.name);
|
|
57
|
+
if (this.get(frontmatter.name)) {
|
|
58
|
+
throw new Error(`skill with name "${frontmatter.name}" already exists`);
|
|
59
|
+
}
|
|
60
|
+
const skillDir = resolveWithinBase(this.sourceDir, folder, frontmatter.name);
|
|
61
|
+
if (fs.existsSync(skillDir)) {
|
|
62
|
+
throw new Error(`skill directory already exists at ${skillDir}`);
|
|
63
|
+
}
|
|
64
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
65
|
+
const filePath = path.join(skillDir, 'SKILL.md');
|
|
66
|
+
const fm = {
|
|
67
|
+
name: frontmatter.name,
|
|
68
|
+
description: frontmatter.description,
|
|
69
|
+
license: frontmatter.license,
|
|
70
|
+
compatibility: frontmatter.compatibility,
|
|
71
|
+
tags: frontmatter.tags ?? [],
|
|
72
|
+
trigger_phrases: frontmatter.trigger_phrases ?? [],
|
|
73
|
+
metadata: {
|
|
74
|
+
owner: frontmatter.owner ?? null,
|
|
75
|
+
status: frontmatter.status ?? 'unreviewed',
|
|
76
|
+
extends: frontmatter.extends ?? null,
|
|
77
|
+
},
|
|
78
|
+
source_path: filePath,
|
|
79
|
+
};
|
|
80
|
+
writeMarkdownFile(filePath, stripSourcePath(fm), body);
|
|
81
|
+
upsertFile(this.db, this.syncSpec, filePath);
|
|
82
|
+
return { ...fm, body };
|
|
83
|
+
}
|
|
84
|
+
update(name, frontmatter, body) {
|
|
85
|
+
const existing = this.get(name);
|
|
86
|
+
if (!existing)
|
|
87
|
+
throw new Error(`skill with name "${name}" not found`);
|
|
88
|
+
const merged = {
|
|
89
|
+
...existing,
|
|
90
|
+
...frontmatter,
|
|
91
|
+
name: existing.name, // name is immutable post-creation (it's also the folder name)
|
|
92
|
+
metadata: {
|
|
93
|
+
...existing.metadata,
|
|
94
|
+
owner: frontmatter?.owner !== undefined ? frontmatter.owner : existing.metadata.owner,
|
|
95
|
+
status: frontmatter?.status ?? existing.metadata.status,
|
|
96
|
+
extends: frontmatter?.extends !== undefined ? frontmatter.extends : existing.metadata.extends,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const newBody = body ?? existing.body;
|
|
100
|
+
writeMarkdownFile(existing.source_path, stripSourcePath(merged), newBody);
|
|
101
|
+
upsertFile(this.db, this.syncSpec, existing.source_path);
|
|
102
|
+
return { ...merged, body: newBody };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Renames a skill: moves <sourceDir>/[folder/]<oldName>/ to .../<newName>/ (keeping any
|
|
106
|
+
* scripts/references/assets alongside SKILL.md) and updates the `name` frontmatter field to match.
|
|
107
|
+
*/
|
|
108
|
+
rename(name, newName) {
|
|
109
|
+
assertValidSkillName(newName);
|
|
110
|
+
const existing = this.get(name);
|
|
111
|
+
if (!existing)
|
|
112
|
+
throw new Error(`skill with name "${name}" not found`);
|
|
113
|
+
if (newName === name)
|
|
114
|
+
return existing;
|
|
115
|
+
if (this.get(newName)) {
|
|
116
|
+
throw new Error(`skill with name "${newName}" already exists`);
|
|
117
|
+
}
|
|
118
|
+
const oldDir = path.dirname(existing.source_path);
|
|
119
|
+
const newDir = path.join(path.dirname(oldDir), newName);
|
|
120
|
+
if (fs.existsSync(newDir)) {
|
|
121
|
+
throw new Error(`skill directory already exists at ${newDir}`);
|
|
122
|
+
}
|
|
123
|
+
fs.renameSync(oldDir, newDir);
|
|
124
|
+
const newFilePath = path.join(newDir, 'SKILL.md');
|
|
125
|
+
const merged = { ...existing, name: newName };
|
|
126
|
+
writeMarkdownFile(newFilePath, stripSourcePath(merged), existing.body);
|
|
127
|
+
removeFile(this.db, 'skills', existing.source_path);
|
|
128
|
+
upsertFile(this.db, this.syncSpec, newFilePath);
|
|
129
|
+
return { ...merged, body: existing.body };
|
|
130
|
+
}
|
|
131
|
+
/** Removes the whole skill directory, including any scripts/references/assets alongside SKILL.md. */
|
|
132
|
+
delete(name) {
|
|
133
|
+
const existing = this.get(name);
|
|
134
|
+
if (!existing)
|
|
135
|
+
throw new Error(`skill with name "${name}" not found`);
|
|
136
|
+
const skillDir = path.dirname(existing.source_path);
|
|
137
|
+
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
138
|
+
removeFile(this.db, 'skills', existing.source_path);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function stripSourcePath(fm) {
|
|
142
|
+
const { source_path: _sp, ...rest } = fm;
|
|
143
|
+
return rest;
|
|
144
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const SKILL_STATUS = ['stable', 'beta', 'unreviewed'];
|
|
3
|
+
const SKILL_NAME_DESCRIPTION = 'stable id, must be 1-64 chars, lowercase letters/numbers/hyphens only, no leading/trailing/consecutive hyphens — this becomes the skill\'s folder name (agentskills.io spec requirement)';
|
|
4
|
+
const AUTHORING_SKILL_HINT = "Before your first call in a session, run skill_get(\"memory-bucket-authoring\") to learn the exact frontmatter schema and conventions — don't guess the shape.";
|
|
5
|
+
export function registerSkillTools(mcp, repo) {
|
|
6
|
+
mcp.tool('skill_list', 'Lists skills (reusable coding patterns, one SKILL.md per folder per the agentskills.io open standard), optionally filtered by a keyword matched against description/tags/trigger phrases.', { query: z.string().optional() }, async ({ query }) => {
|
|
7
|
+
const items = repo.list(query);
|
|
8
|
+
return { content: [{ type: 'text', text: JSON.stringify(items, null, 2) }] };
|
|
9
|
+
});
|
|
10
|
+
mcp.tool('skill_get', 'Fetches a single skill by name, including its full markdown body.', { name: z.string() }, async ({ name }) => {
|
|
11
|
+
const doc = repo.get(name);
|
|
12
|
+
if (!doc) {
|
|
13
|
+
return { content: [{ type: 'text', text: `No skill found with name "${name}"` }], isError: true };
|
|
14
|
+
}
|
|
15
|
+
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
16
|
+
});
|
|
17
|
+
mcp.tool('skill_create', `Creates a new skill as <sourceDir>/[folder/]<name>/SKILL.md, per the agentskills.io open standard — a folder containing SKILL.md, optionally alongside scripts/references/assets subfolders you create separately on disk. ${AUTHORING_SKILL_HINT}`, {
|
|
18
|
+
name: z.string().describe(SKILL_NAME_DESCRIPTION),
|
|
19
|
+
description: z
|
|
20
|
+
.string()
|
|
21
|
+
.max(1024)
|
|
22
|
+
.describe('required by spec: what the skill does AND when to use it — this is what agents scan to decide relevance, so be specific'),
|
|
23
|
+
body: z.string().describe('markdown body of SKILL.md — the instructions, loaded only once the skill is activated'),
|
|
24
|
+
license: z.string().optional(),
|
|
25
|
+
compatibility: z.string().max(500).optional().describe('only needed if the skill has specific environment requirements'),
|
|
26
|
+
owner: z.string().optional().describe('squad or "company" — stored in frontmatter.metadata, unused for resolution in V0'),
|
|
27
|
+
status: z.enum(SKILL_STATUS).optional().describe('defaults to "unreviewed"; stored in frontmatter.metadata'),
|
|
28
|
+
tags: z.array(z.string()).optional(),
|
|
29
|
+
trigger_phrases: z.array(z.string()).optional(),
|
|
30
|
+
extends: z.string().optional().describe('reserved for a future overlay mechanism — stored in frontmatter.metadata'),
|
|
31
|
+
folder: z.string().optional().describe('optional subdirectory under the skill source dir, e.g. "frontend"'),
|
|
32
|
+
}, async ({ name, description, body, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId, folder }) => {
|
|
33
|
+
try {
|
|
34
|
+
const doc = repo.create({ name, description, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId }, body, folder);
|
|
35
|
+
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
mcp.tool('skill_update', `Edits an existing skill in place — frontmatter fields and/or body. Only provided fields change. Use skill_rename to change the name/folder. ${AUTHORING_SKILL_HINT}`, {
|
|
42
|
+
name: z.string(),
|
|
43
|
+
description: z.string().max(1024).optional(),
|
|
44
|
+
body: z.string().optional(),
|
|
45
|
+
license: z.string().optional(),
|
|
46
|
+
compatibility: z.string().max(500).optional(),
|
|
47
|
+
owner: z.string().optional(),
|
|
48
|
+
status: z.enum(SKILL_STATUS).optional(),
|
|
49
|
+
tags: z.array(z.string()).optional(),
|
|
50
|
+
trigger_phrases: z.array(z.string()).optional(),
|
|
51
|
+
extends: z.string().optional(),
|
|
52
|
+
}, async ({ name, body, ...frontmatterFields }) => {
|
|
53
|
+
try {
|
|
54
|
+
const doc = repo.update(name, frontmatterFields, body);
|
|
55
|
+
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
mcp.tool('skill_rename', 'Renames a skill: moves its folder to the new name and updates the frontmatter `name` field to match, preserving any scripts/references/assets alongside SKILL.md.', {
|
|
62
|
+
name: z.string().describe('current skill name'),
|
|
63
|
+
new_name: z.string().describe(SKILL_NAME_DESCRIPTION),
|
|
64
|
+
}, async ({ name, new_name }) => {
|
|
65
|
+
try {
|
|
66
|
+
const doc = repo.rename(name, new_name);
|
|
67
|
+
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
mcp.tool('skill_delete', 'Hard-deletes a skill by name — removes the whole skill folder (SKILL.md plus any scripts/references/assets), no tombstone.', { name: z.string() }, async ({ name }) => {
|
|
74
|
+
try {
|
|
75
|
+
repo.delete(name);
|
|
76
|
+
return { content: [{ type: 'text', text: `Deleted skill "${name}"` }] };
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
export function openCache(dbPath) {
|
|
3
|
+
const db = new Database(dbPath);
|
|
4
|
+
db.pragma('journal_mode = WAL');
|
|
5
|
+
db.exec(`
|
|
6
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
7
|
+
id TEXT PRIMARY KEY, -- == SKILL.md "name" field == parent folder name
|
|
8
|
+
description TEXT NOT NULL, -- required by the agentskills.io spec
|
|
9
|
+
owner TEXT,
|
|
10
|
+
status TEXT NOT NULL,
|
|
11
|
+
tags TEXT NOT NULL, -- JSON array
|
|
12
|
+
trigger_phrases TEXT NOT NULL, -- JSON array
|
|
13
|
+
extends TEXT,
|
|
14
|
+
source_path TEXT NOT NULL UNIQUE, -- path to SKILL.md
|
|
15
|
+
body TEXT NOT NULL,
|
|
16
|
+
mtime_ms INTEGER NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS memory_docs (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
key TEXT NOT NULL,
|
|
22
|
+
key_type TEXT NOT NULL,
|
|
23
|
+
description TEXT NOT NULL,
|
|
24
|
+
doc_type TEXT NOT NULL,
|
|
25
|
+
tags TEXT NOT NULL, -- JSON array
|
|
26
|
+
status TEXT NOT NULL,
|
|
27
|
+
related_to TEXT,
|
|
28
|
+
source_path TEXT NOT NULL UNIQUE,
|
|
29
|
+
body TEXT NOT NULL,
|
|
30
|
+
mtime_ms INTEGER NOT NULL
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_memory_docs_key ON memory_docs(key);
|
|
34
|
+
|
|
35
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
36
|
+
ref_table UNINDEXED,
|
|
37
|
+
ref_id UNINDEXED,
|
|
38
|
+
description,
|
|
39
|
+
body,
|
|
40
|
+
tags,
|
|
41
|
+
tokenize = 'porter unicode61'
|
|
42
|
+
);
|
|
43
|
+
`);
|
|
44
|
+
backfillSearchIndex(db);
|
|
45
|
+
return db;
|
|
46
|
+
}
|
|
47
|
+
/** One-time backfill for existing rows the first time search_index is introduced into a cache file. */
|
|
48
|
+
function backfillSearchIndex(db) {
|
|
49
|
+
const { count: indexed } = db.prepare(`SELECT COUNT(*) as count FROM search_index`).get();
|
|
50
|
+
if (indexed > 0)
|
|
51
|
+
return;
|
|
52
|
+
const skillRows = db.prepare(`SELECT id, description, body, tags FROM skills`).all();
|
|
53
|
+
const memoryRows = db.prepare(`SELECT id, description, body, tags FROM memory_docs`).all();
|
|
54
|
+
if (skillRows.length === 0 && memoryRows.length === 0)
|
|
55
|
+
return;
|
|
56
|
+
const insert = db.prepare(`INSERT INTO search_index (ref_table, ref_id, description, body, tags) VALUES (?, ?, ?, ?, ?)`);
|
|
57
|
+
const insertAll = db.transaction(() => {
|
|
58
|
+
for (const row of skillRows) {
|
|
59
|
+
insert.run('skills', row.id, row.description, row.body, flattenTags(row.tags));
|
|
60
|
+
}
|
|
61
|
+
for (const row of memoryRows) {
|
|
62
|
+
insert.run('memory_docs', row.id, row.description, row.body, flattenTags(row.tags));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
insertAll();
|
|
66
|
+
}
|
|
67
|
+
export function flattenTags(tagsJson) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(tagsJson).join(' ');
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import matter from 'gray-matter';
|
|
3
|
+
export function readMarkdownFile(filePath) {
|
|
4
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
5
|
+
const parsed = matter(raw);
|
|
6
|
+
const stat = fs.statSync(filePath);
|
|
7
|
+
return {
|
|
8
|
+
frontmatter: parsed.data,
|
|
9
|
+
body: parsed.content.trim(),
|
|
10
|
+
mtimeMs: stat.mtimeMs,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function writeMarkdownFile(filePath, frontmatter, body) {
|
|
14
|
+
// js-yaml throws on `undefined` values rather than omitting them, so strip
|
|
15
|
+
// optional-but-unset fields (e.g. skill license/compatibility) before dump.
|
|
16
|
+
const cleaned = Object.fromEntries(Object.entries(frontmatter).filter(([, v]) => v !== undefined));
|
|
17
|
+
const content = matter.stringify(`${body}\n`, cleaned);
|
|
18
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/** Joins `baseDir` with an optional caller-supplied subfolder, rejecting any attempt to escape baseDir. */
|
|
3
|
+
export function resolveWithinBase(baseDir, folder, fileName) {
|
|
4
|
+
const target = folder ? path.join(baseDir, folder, fileName) : path.join(baseDir, fileName);
|
|
5
|
+
const resolvedBase = path.resolve(baseDir);
|
|
6
|
+
const resolvedTarget = path.resolve(target);
|
|
7
|
+
if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) {
|
|
8
|
+
throw new Error(`folder "${folder}" escapes the source directory`);
|
|
9
|
+
}
|
|
10
|
+
return resolvedTarget;
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Per the agentskills.io spec: 1-64 chars, lowercase alphanumeric + hyphens,
|
|
2
|
+
// no leading/trailing hyphen, no consecutive hyphens.
|
|
3
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
4
|
+
export function isValidSkillName(name) {
|
|
5
|
+
return name.length >= 1 && name.length <= 64 && SKILL_NAME_PATTERN.test(name);
|
|
6
|
+
}
|
|
7
|
+
export function assertValidSkillName(name) {
|
|
8
|
+
if (!isValidSkillName(name)) {
|
|
9
|
+
throw new Error(`invalid skill name "${name}": must be 1-64 chars, lowercase letters/numbers/hyphens only, no leading/trailing/consecutive hyphens`);
|
|
10
|
+
}
|
|
11
|
+
}
|