mcp-memory-bucket 0.2.2 → 0.3.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.
@@ -12,6 +12,35 @@ export function registerSkillTools(mcp, repo) {
12
12
  const items = repo.list(query, root);
13
13
  return { content: [{ type: 'text', text: JSON.stringify(items, null, 2) }] };
14
14
  });
15
+ mcp.tool('skill_search', 'Full-text search over skill description/body/tags (grep/find-like, ranked by relevance) — unlike skill_list\'s substring metadata filter, this searches the full markdown body. `query` is raw SQLite FTS5 MATCH syntax: bare words, "exact phrases", prefix* wildcards, AND/OR/NOT boolean operators; hyphenated/punctuated terms must be quoted, e.g. "blue-green". Can be combined with status/owner/tag filters. Returns ranked hits with a highlighted snippet, not the full body — call skill_get on a hit\'s name for that.', {
16
+ query: z.string().describe('FTS5 match expression, e.g. `deploy AND rollback` or `"blue green"`'),
17
+ status: z.enum(SKILL_STATUS).optional(),
18
+ owner: z.string().optional(),
19
+ tag: z.string().optional(),
20
+ limit: z.number().int().positive().max(100).optional(),
21
+ offset: z.number().int().nonnegative().optional(),
22
+ ...(multiRoot ? { root: z.string().optional().describe(`filter to one root: ${rootNames}`) } : {}),
23
+ }, async ({ query, status, owner, tag, limit, offset, root }) => {
24
+ try {
25
+ const hits = repo.search(query, { root, status, owner, tag, limit, offset });
26
+ return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
27
+ }
28
+ catch (err) {
29
+ return { content: [{ type: 'text', text: err.message }], isError: true };
30
+ }
31
+ });
32
+ mcp.tool('skill_bulk_update', 'Applies the same frontmatter change to many skills at once by name — e.g. add/remove a tag across a batch found via skill_search, or flip status for a group. add_tags/remove_tags merge or subtract per-skill; owner/status/extends overwrite uniformly when provided. Body is never touched. Returns per-name success/failure so one bad name doesn\'t abort the batch.', {
33
+ names: z.array(z.string()).min(1),
34
+ add_tags: z.array(z.string()).optional(),
35
+ remove_tags: z.array(z.string()).optional(),
36
+ owner: z.string().nullable().optional(),
37
+ status: z.enum(SKILL_STATUS).optional(),
38
+ extends: z.string().nullable().optional(),
39
+ deprecated: z.boolean().optional().describe('marks skills as deprecated (or un-deprecates when false) — independent of status'),
40
+ }, async ({ names, add_tags, remove_tags, owner, status, extends: extendsId, deprecated }) => {
41
+ const results = repo.bulkUpdate(names, { add_tags, remove_tags, owner, status, extends: extendsId, deprecated });
42
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
43
+ });
15
44
  mcp.tool('skill_get', 'Fetches a single skill by name, including its full markdown body.', { name: z.string() }, async ({ name }) => {
16
45
  const doc = repo.get(name);
17
46
  if (!doc) {
@@ -19,6 +48,10 @@ export function registerSkillTools(mcp, repo) {
19
48
  }
20
49
  return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
21
50
  });
51
+ mcp.tool('skill_bulk_get', 'Fetches many skills by name in one call, including full markdown bodies — e.g. hydrating a batch of skill_search hits. Missing names are simply omitted from the result, not errors.', { names: z.array(z.string()).min(1) }, async ({ names }) => {
52
+ const docs = repo.bulkGet(names);
53
+ return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
54
+ });
22
55
  mcp.tool('skill_create', `Creates a new skill as <root>/[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}`, {
23
56
  name: z.string().describe(SKILL_NAME_DESCRIPTION),
24
57
  description: z
@@ -44,6 +77,39 @@ export function registerSkillTools(mcp, repo) {
44
77
  return { content: [{ type: 'text', text: err.message }], isError: true };
45
78
  }
46
79
  });
80
+ const skillEntrySchema = z.object({
81
+ name: z.string().describe(SKILL_NAME_DESCRIPTION),
82
+ description: z.string().max(1024).describe('required by spec: what the skill does AND when to use it'),
83
+ body: z.string().describe('markdown body of SKILL.md'),
84
+ license: z.string().optional(),
85
+ compatibility: z.string().max(500).optional(),
86
+ owner: z.string().optional(),
87
+ status: z.enum(SKILL_STATUS).optional(),
88
+ tags: z.array(z.string()).optional(),
89
+ trigger_phrases: z.array(z.string()).optional(),
90
+ extends: z.string().optional(),
91
+ folder: z.string().optional(),
92
+ ...(multiRoot ? { root: z.string().describe(`which configured skill root to write into: ${rootNames}`) } : {}),
93
+ });
94
+ mcp.tool('skill_bulk_create', `Creates many skills in one call — each entry is the same shape as skill_create's args. Returns per-name success/failure so one bad entry (duplicate name, invalid name, existing directory) doesn't abort the rest of the batch. ${AUTHORING_SKILL_HINT}`, { entries: z.array(skillEntrySchema).min(1) }, async ({ entries }) => {
95
+ const results = repo.bulkCreate(entries.map((e) => ({
96
+ frontmatter: {
97
+ name: e.name,
98
+ description: e.description,
99
+ license: e.license,
100
+ compatibility: e.compatibility,
101
+ owner: e.owner,
102
+ status: e.status,
103
+ tags: e.tags,
104
+ trigger_phrases: e.trigger_phrases,
105
+ extends: e.extends,
106
+ },
107
+ body: e.body,
108
+ folder: e.folder,
109
+ root: e.root,
110
+ })));
111
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
112
+ });
47
113
  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}`, {
48
114
  name: z.string(),
49
115
  description: z.string().max(1024).optional(),
@@ -55,6 +121,7 @@ export function registerSkillTools(mcp, repo) {
55
121
  tags: z.array(z.string()).optional(),
56
122
  trigger_phrases: z.array(z.string()).optional(),
57
123
  extends: z.string().optional(),
124
+ deprecated: z.boolean().optional().describe('marks the skill as deprecated (or un-deprecates when false) — independent of status'),
58
125
  }, async ({ name, body, ...frontmatterFields }) => {
59
126
  try {
60
127
  const doc = repo.update(name, frontmatterFields, body);
@@ -85,4 +152,8 @@ export function registerSkillTools(mcp, repo) {
85
152
  return { content: [{ type: 'text', text: err.message }], isError: true };
86
153
  }
87
154
  });
155
+ mcp.tool('skill_bulk_delete', 'Hard-deletes many skills by name in one call — e.g. cleaning up a batch found via skill_search/skill_list. No tombstone. Returns per-name success/failure so one bad name doesn\'t abort the rest of the batch.', { names: z.array(z.string()).min(1) }, async ({ names }) => {
156
+ const results = repo.bulkDelete(names);
157
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
158
+ });
88
159
  }
@@ -13,6 +13,8 @@ export function openCache(dbPath) {
13
13
  extends TEXT,
14
14
  source_path TEXT NOT NULL UNIQUE, -- path to SKILL.md
15
15
  root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
16
+ deprecated INTEGER NOT NULL DEFAULT 0,
17
+ created_at TEXT,
16
18
  body TEXT NOT NULL,
17
19
  mtime_ms INTEGER NOT NULL
18
20
  );
@@ -28,6 +30,8 @@ export function openCache(dbPath) {
28
30
  related_to TEXT,
29
31
  source_path TEXT NOT NULL UNIQUE,
30
32
  root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
33
+ deprecated INTEGER NOT NULL DEFAULT 0,
34
+ created_at TEXT,
31
35
  body TEXT NOT NULL,
32
36
  mtime_ms INTEGER NOT NULL
33
37
  );
@@ -43,17 +47,26 @@ export function openCache(dbPath) {
43
47
  tokenize = 'porter unicode61'
44
48
  );
45
49
  `);
46
- addRootColumnIfMissing(db, 'skills');
47
- addRootColumnIfMissing(db, 'memory_docs');
50
+ ensureColumns(db, 'skills', [
51
+ ['root', "TEXT NOT NULL DEFAULT ''"],
52
+ ['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
53
+ ['created_at', 'TEXT'],
54
+ ]);
55
+ ensureColumns(db, 'memory_docs', [
56
+ ['root', "TEXT NOT NULL DEFAULT ''"],
57
+ ['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
58
+ ['created_at', 'TEXT'],
59
+ ]);
48
60
  backfillSearchIndex(db);
49
61
  return db;
50
62
  }
51
- /** Migration for cache files created before the `root` column existed. */
52
- function addRootColumnIfMissing(db, table) {
53
- const columns = db.prepare(`PRAGMA table_info(${table})`).all();
54
- if (columns.some((c) => c.name === 'root'))
55
- return;
56
- db.exec(`ALTER TABLE ${table} ADD COLUMN root TEXT NOT NULL DEFAULT ''`);
63
+ /** Migration for cache files created before a given column existed. Safe to call every startup. */
64
+ function ensureColumns(db, table, columns) {
65
+ const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
66
+ for (const [name, ddlType] of columns) {
67
+ if (!existing.has(name))
68
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${ddlType}`);
69
+ }
57
70
  }
58
71
  /** One-time backfill for existing rows the first time search_index is introduced into a cache file. */
59
72
  function backfillSearchIndex(db) {
@@ -0,0 +1,60 @@
1
+ /** A quoted FTS5 syntax error, translated into something an agent can act on without seeing raw SQLite internals. */
2
+ export class SearchQueryError extends Error {
3
+ constructor(query, cause) {
4
+ super(`invalid search query "${query}": ${cause.message}. ` +
5
+ `FTS5 syntax notes: hyphenated/punctuated words must be quoted (e.g. "blue-green"), ` +
6
+ `bare words are AND'd by default, use OR/NOT for other logic, "exact phrase" for phrases, and prefix* for prefix matching.`);
7
+ this.name = 'SearchQueryError';
8
+ }
9
+ }
10
+ /**
11
+ * Full-text search over the shared FTS5 index, optionally scoped to one ref_table.
12
+ * `query` is passed through as raw FTS5 MATCH syntax — supports `AND`/`OR`/`NOT`,
13
+ * `"exact phrases"`, and `prefix*` — so callers get grep-like power for free.
14
+ * Ranked by bm25() (lower is better, so we negate for a "higher is better" score).
15
+ * Throws SearchQueryError on malformed FTS5 syntax instead of a raw SQLite error.
16
+ */
17
+ export function searchIndex(db, query, opts = {}) {
18
+ const { table, limit = 20, offset = 0 } = opts;
19
+ try {
20
+ const rows = db
21
+ .prepare(`SELECT ref_table, ref_id,
22
+ snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
23
+ -bm25(search_index) AS score
24
+ FROM search_index
25
+ WHERE search_index MATCH ? ${table ? 'AND ref_table = ?' : ''}
26
+ ORDER BY bm25(search_index)
27
+ LIMIT ? OFFSET ?`)
28
+ .all(...(table ? [query, table, limit, offset] : [query, limit, offset]));
29
+ return rows;
30
+ }
31
+ catch (err) {
32
+ throw new SearchQueryError(query, err);
33
+ }
34
+ }
35
+ /**
36
+ * Full-text search across BOTH skills and memory docs in one ranked list —
37
+ * for the common case of "find where I put X" when the caller doesn't know
38
+ * which bucket it landed in. No metadata filters (doc_type/status/tag differ
39
+ * per table); use skill_search/memory_search directly when filtering by those.
40
+ */
41
+ export function searchCombined(db, query, limit = 20, offset = 0) {
42
+ try {
43
+ return db
44
+ .prepare(`SELECT ref_table, ref_id AS id,
45
+ COALESCE(s.description, m.description) AS description,
46
+ COALESCE(s.root, m.root) AS root,
47
+ snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
48
+ -bm25(search_index) AS score
49
+ FROM search_index
50
+ LEFT JOIN skills s ON search_index.ref_table = 'skills' AND s.id = search_index.ref_id
51
+ LEFT JOIN memory_docs m ON search_index.ref_table = 'memory_docs' AND m.id = search_index.ref_id
52
+ WHERE search_index MATCH ?
53
+ ORDER BY bm25(search_index)
54
+ LIMIT ? OFFSET ?`)
55
+ .all(query, limit, offset);
56
+ }
57
+ catch (err) {
58
+ throw new SearchQueryError(query, err);
59
+ }
60
+ }
@@ -3,8 +3,8 @@ import path from 'node:path';
3
3
  import chokidar, {} from 'chokidar';
4
4
  import { readMarkdownFile } from './markdown-file.js';
5
5
  import { flattenTags } from './db.js';
6
- const skillColumns = ['id', 'description', 'owner', 'status', 'tags', 'trigger_phrases', 'extends'];
7
- const memoryColumns = ['id', 'key', 'key_type', 'description', 'doc_type', 'tags', 'status', 'related_to'];
6
+ const skillColumns = ['id', 'description', 'owner', 'status', 'tags', 'trigger_phrases', 'extends', 'deprecated', 'created_at'];
7
+ const memoryColumns = ['id', 'key', 'key_type', 'description', 'doc_type', 'tags', 'status', 'related_to', 'deprecated', 'created_at'];
8
8
  export function skillSyncSpec(sources) {
9
9
  return {
10
10
  table: 'skills',
@@ -20,6 +20,8 @@ export function skillSyncSpec(sources) {
20
20
  tags: JSON.stringify(fm.tags ?? []),
21
21
  trigger_phrases: JSON.stringify(fm.trigger_phrases ?? []),
22
22
  extends: fm.metadata?.extends ?? null,
23
+ deprecated: fm.deprecated ? 1 : 0,
24
+ created_at: fm.created_at ?? null,
23
25
  }),
24
26
  };
25
27
  }
@@ -39,6 +41,8 @@ export function memorySyncSpec(sources) {
39
41
  tags: JSON.stringify(fm.tags ?? []),
40
42
  status: fm.status ?? 'active',
41
43
  related_to: fm.related_to ?? null,
44
+ deprecated: fm.deprecated ? 1 : 0,
45
+ created_at: fm.created_at ?? null,
42
46
  }),
43
47
  };
44
48
  }
@@ -23,6 +23,8 @@ function queryEntries(db, req) {
23
23
  const keyTypes = asArray(req.query.key_type);
24
24
  const roots = asArray(req.query.root);
25
25
  const q = req.query.q?.trim();
26
+ const deprecatedParam = req.query.deprecated;
27
+ const deprecated = deprecatedParam === '0' || deprecatedParam === '1' ? deprecatedParam : undefined;
26
28
  const matchedIds = q
27
29
  ? matchSearch(db, q)
28
30
  : null;
@@ -31,10 +33,10 @@ function queryEntries(db, req) {
31
33
  }
32
34
  const results = [];
33
35
  if (type === 'skill' || type === 'all') {
34
- results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], roots }, matchedIds?.skills));
36
+ results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], roots, deprecated }, matchedIds?.skills));
35
37
  }
36
38
  if (type === 'memory' || type === 'all') {
37
- results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, roots }, matchedIds?.memory_docs));
39
+ results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, roots, deprecated }, matchedIds?.memory_docs));
38
40
  }
39
41
  const sort = req.query.sort ?? 'mtime_desc';
40
42
  results.sort((a, b) => {
@@ -42,6 +44,15 @@ function queryEntries(db, req) {
42
44
  return a.mtime_ms - b.mtime_ms;
43
45
  if (sort === 'name_asc')
44
46
  return a.name.localeCompare(b.name);
47
+ if (sort === 'created_at_asc') {
48
+ if (!a.created_at && !b.created_at)
49
+ return 0;
50
+ if (!a.created_at)
51
+ return 1; // missing created_at sorts last
52
+ if (!b.created_at)
53
+ return -1;
54
+ return a.created_at.localeCompare(b.created_at);
55
+ }
45
56
  return b.mtime_ms - a.mtime_ms; // mtime_desc, default
46
57
  });
47
58
  return results;
@@ -74,13 +85,17 @@ function queryTable(db, table, filters, restrictToIds) {
74
85
  where += ` AND root IN (${filters.roots.map(() => '?').join(', ')})`;
75
86
  params.push(...filters.roots);
76
87
  }
88
+ if (filters.deprecated !== undefined) {
89
+ where += ` AND deprecated = ?`;
90
+ params.push(filters.deprecated === '1' ? 1 : 0);
91
+ }
77
92
  if (restrictToIds) {
78
93
  where += ` AND id IN (${[...restrictToIds].map(() => '?').join(', ')})`;
79
94
  params.push(...restrictToIds);
80
95
  }
81
96
  if (table === 'skills') {
82
97
  const rows = db
83
- .prepare(`SELECT id, description, owner, status, tags, root, mtime_ms FROM skills WHERE ${where}`)
98
+ .prepare(`SELECT id, description, owner, status, tags, root, mtime_ms, deprecated, created_at FROM skills WHERE ${where}`)
84
99
  .all(...params);
85
100
  return rows.map((r) => ({
86
101
  _table: 'skills',
@@ -94,10 +109,12 @@ function queryTable(db, table, filters, restrictToIds) {
94
109
  key_type: null,
95
110
  root: r.root,
96
111
  mtime_ms: r.mtime_ms,
112
+ deprecated: !!r.deprecated,
113
+ created_at: r.created_at,
97
114
  }));
98
115
  }
99
116
  const rows = db
100
- .prepare(`SELECT id, key, description, doc_type, key_type, status, tags, root, mtime_ms FROM memory_docs WHERE ${where}`)
117
+ .prepare(`SELECT id, key, description, doc_type, key_type, status, tags, root, mtime_ms, deprecated, created_at FROM memory_docs WHERE ${where}`)
101
118
  .all(...params);
102
119
  return rows.map((r) => ({
103
120
  _table: 'memory_docs',
@@ -111,6 +128,8 @@ function queryTable(db, table, filters, restrictToIds) {
111
128
  key_type: r.key_type,
112
129
  root: r.root,
113
130
  mtime_ms: r.mtime_ms,
131
+ deprecated: !!r.deprecated,
132
+ created_at: r.created_at,
114
133
  }));
115
134
  }
116
135
  /** Runs the FTS5 query once and buckets matching ids by source table. */
@@ -219,6 +238,81 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo) {
219
238
  const trigger_phrases = row.trigger_phrases ? JSON.parse(row.trigger_phrases) : undefined;
220
239
  res.json({ ...row, tags, trigger_phrases });
221
240
  });
241
+ router.patch('/api/entries/:table/:id/deprecated', (req, res) => {
242
+ const { table, id } = req.params;
243
+ const { deprecated } = req.body;
244
+ if (table !== 'skills' && table !== 'memory_docs') {
245
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
246
+ return;
247
+ }
248
+ if (!id) {
249
+ res.status(400).json({ error: 'id is required' });
250
+ return;
251
+ }
252
+ if (typeof deprecated !== 'boolean') {
253
+ res.status(400).json({ error: 'body must be { deprecated: boolean }' });
254
+ return;
255
+ }
256
+ try {
257
+ if (table === 'skills')
258
+ skillRepo.update(id, { deprecated });
259
+ else
260
+ memoryRepo.update(id, { deprecated });
261
+ res.json({ id, deprecated });
262
+ }
263
+ catch (err) {
264
+ res.status(404).json({ error: err.message });
265
+ }
266
+ });
267
+ router.post('/api/entries/:table/bulk/deprecated', (req, res) => {
268
+ const { table } = req.params;
269
+ const { ids, deprecated } = req.body;
270
+ if (table !== 'skills' && table !== 'memory_docs') {
271
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
272
+ return;
273
+ }
274
+ if (!Array.isArray(ids) || ids.length === 0 || typeof deprecated !== 'boolean') {
275
+ res.status(400).json({ error: 'body must be { ids: string[], deprecated: boolean }' });
276
+ return;
277
+ }
278
+ const results = table === 'skills' ? skillRepo.bulkUpdate(ids, { deprecated }) : memoryRepo.bulkUpdate(ids, { deprecated });
279
+ res.json({ results });
280
+ });
281
+ router.delete('/api/entries/:table/:id', (req, res) => {
282
+ const { table, id } = req.params;
283
+ if (table !== 'skills' && table !== 'memory_docs') {
284
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
285
+ return;
286
+ }
287
+ if (!id) {
288
+ res.status(400).json({ error: 'id is required' });
289
+ return;
290
+ }
291
+ try {
292
+ if (table === 'skills')
293
+ skillRepo.delete(id);
294
+ else
295
+ memoryRepo.delete(id);
296
+ res.json({ deleted: id });
297
+ }
298
+ catch (err) {
299
+ res.status(404).json({ error: err.message });
300
+ }
301
+ });
302
+ router.post('/api/entries/:table/bulk/delete', (req, res) => {
303
+ const { table } = req.params;
304
+ const { ids } = req.body;
305
+ if (table !== 'skills' && table !== 'memory_docs') {
306
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
307
+ return;
308
+ }
309
+ if (!Array.isArray(ids) || ids.length === 0) {
310
+ res.status(400).json({ error: 'body must be { ids: string[] }' });
311
+ return;
312
+ }
313
+ const results = table === 'skills' ? skillRepo.bulkDelete(ids) : memoryRepo.bulkDelete(ids);
314
+ res.json({ results });
315
+ });
222
316
  router.get('/api/facets', (req, res) => {
223
317
  const type = req.query.type ?? 'all';
224
318
  res.json(buildFacets(db, type));
@@ -1,5 +1,5 @@
1
1
  export function registerUiTool(mcp, port) {
2
- mcp.tool('bucket_open_ui', 'Returns the URL for the mem-bucket web viewer — a read-only browser UI for searching/filtering skills and memory docs by tag, status, owner, and fulltext. Not for editing; use the skill_*/memory_* tools for that.', {}, async () => {
2
+ mcp.tool('bucket_open_ui', 'Returns the URL for the mem-bucket web viewer — a browser UI for searching/filtering skills and memory docs by tag, status, owner, deprecated flag, and fulltext, sorting by creation date, and marking items deprecated or deleting them (single or bulk). For anything beyond that, use the skill_*/memory_* tools instead.', {}, async () => {
3
3
  const url = `http://localhost:${port}/`;
4
4
  return { content: [{ type: 'text', text: url }] };
5
5
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-bucket",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "MCP server exposing skill_* (reusable coding patterns) and memory_* (point-in-time working context) tools over a markdown+frontmatter source, cached into SQLite at runtime.",
6
6
  "repository": {