@hasna/prompts 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -109,6 +109,10 @@ function runMigrations(db) {
109
109
  CREATE INDEX IF NOT EXISTS idx_versions_prompt_id ON prompt_versions(prompt_id);
110
110
  `
111
111
  },
112
+ {
113
+ name: "003_pinned",
114
+ sql: `ALTER TABLE prompts ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;`
115
+ },
112
116
  {
113
117
  name: "002_fts5",
114
118
  sql: `
@@ -162,6 +166,15 @@ function resolvePrompt(db, idOrSlug) {
162
166
  const byPrefix = db.query("SELECT id FROM prompts WHERE id LIKE ? LIMIT 2").all(`${idOrSlug}%`);
163
167
  if (byPrefix.length === 1 && byPrefix[0])
164
168
  return byPrefix[0].id;
169
+ const bySlugPrefix = db.query("SELECT id FROM prompts WHERE slug LIKE ? LIMIT 2").all(`${idOrSlug}%`);
170
+ if (bySlugPrefix.length === 1 && bySlugPrefix[0])
171
+ return bySlugPrefix[0].id;
172
+ const bySlugSub = db.query("SELECT id FROM prompts WHERE slug LIKE ? LIMIT 2").all(`%${idOrSlug}%`);
173
+ if (bySlugSub.length === 1 && bySlugSub[0])
174
+ return bySlugSub[0].id;
175
+ const byTitle = db.query("SELECT id FROM prompts WHERE lower(title) LIKE ? LIMIT 2").all(`%${idOrSlug.toLowerCase()}%`);
176
+ if (byTitle.length === 1 && byTitle[0])
177
+ return byTitle[0].id;
165
178
  return null;
166
179
  }
167
180
 
@@ -255,6 +268,36 @@ function movePrompt(promptIdOrSlug, targetCollection) {
255
268
  ]);
256
269
  }
257
270
 
271
+ // src/lib/duplicates.ts
272
+ function tokenize(text) {
273
+ return new Set(text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2));
274
+ }
275
+ function similarity(a, b) {
276
+ const ta = tokenize(a);
277
+ const tb = tokenize(b);
278
+ if (ta.size === 0 || tb.size === 0)
279
+ return 0;
280
+ let shared = 0;
281
+ for (const word of ta) {
282
+ if (tb.has(word))
283
+ shared++;
284
+ }
285
+ return shared / Math.max(ta.size, tb.size);
286
+ }
287
+ function findDuplicates(body, threshold = 0.8, excludeSlug) {
288
+ const all = listPrompts({ limit: 1e4 });
289
+ const matches = [];
290
+ for (const p of all) {
291
+ if (excludeSlug && p.slug === excludeSlug)
292
+ continue;
293
+ const score = similarity(body, p.body);
294
+ if (score >= threshold) {
295
+ matches.push({ prompt: p, score });
296
+ }
297
+ }
298
+ return matches.sort((a, b) => b.score - a.score);
299
+ }
300
+
258
301
  // src/lib/template.ts
259
302
  var VAR_PATTERN = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:\|\s*(.*?)\s*)?\}\}/g;
260
303
  function extractVariables(body) {
@@ -348,6 +391,7 @@ function rowToPrompt(row) {
348
391
  collection: row["collection"],
349
392
  tags: JSON.parse(row["tags"] || "[]"),
350
393
  variables: JSON.parse(row["variables"] || "[]"),
394
+ pinned: Boolean(row["pinned"]),
351
395
  is_template: Boolean(row["is_template"]),
352
396
  source: row["source"],
353
397
  version: row["version"],
@@ -422,7 +466,7 @@ function listPrompts(filter = {}) {
422
466
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
423
467
  const limit = filter.limit ?? 100;
424
468
  const offset = filter.offset ?? 0;
425
- const rows = db.query(`SELECT * FROM prompts ${where} ORDER BY use_count DESC, updated_at DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
469
+ const rows = db.query(`SELECT * FROM prompts ${where} ORDER BY pinned DESC, use_count DESC, updated_at DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
426
470
  return rows.map(rowToPrompt);
427
471
  }
428
472
  function updatePrompt(idOrSlug, input) {
@@ -472,7 +516,13 @@ function usePrompt(idOrSlug) {
472
516
  db.run("UPDATE prompts SET use_count = use_count + 1, last_used_at = datetime('now') WHERE id = ?", [prompt.id]);
473
517
  return requirePrompt(prompt.id);
474
518
  }
475
- function upsertPrompt(input) {
519
+ function pinPrompt(idOrSlug, pinned) {
520
+ const db = getDatabase();
521
+ const prompt = requirePrompt(idOrSlug);
522
+ db.run("UPDATE prompts SET pinned = ?, updated_at = datetime('now') WHERE id = ?", [pinned ? 1 : 0, prompt.id]);
523
+ return requirePrompt(prompt.id);
524
+ }
525
+ function upsertPrompt(input, force = false) {
476
526
  const db = getDatabase();
477
527
  const slug = input.slug || generateSlug(input.title);
478
528
  const existing = db.query("SELECT id FROM prompts WHERE slug = ?").get(slug);
@@ -487,8 +537,16 @@ function upsertPrompt(input) {
487
537
  });
488
538
  return { prompt: prompt2, created: false };
489
539
  }
540
+ let duplicate_warning;
541
+ if (!force && input.body) {
542
+ const dupes = findDuplicates(input.body, 0.8, slug);
543
+ if (dupes.length > 0) {
544
+ const top = dupes[0];
545
+ duplicate_warning = `Similar prompt already exists: "${top.prompt.slug}" (${Math.round(top.score * 100)}% match). Use --force to save anyway.`;
546
+ }
547
+ }
490
548
  const prompt = createPrompt({ ...input, slug });
491
- return { prompt, created: true };
549
+ return { prompt, created: true, duplicate_warning };
492
550
  }
493
551
  function getPromptStats() {
494
552
  const db = getDatabase();
@@ -581,6 +639,7 @@ function rowToSearchResult(row, snippet) {
581
639
  collection: row["collection"],
582
640
  tags: JSON.parse(row["tags"] || "[]"),
583
641
  variables: JSON.parse(row["variables"] || "[]"),
642
+ pinned: Boolean(row["pinned"]),
584
643
  is_template: Boolean(row["is_template"]),
585
644
  source: row["source"],
586
645
  version: row["version"],
@@ -709,6 +768,7 @@ export {
709
768
  requirePrompt,
710
769
  renderTemplate,
711
770
  registerAgent,
771
+ pinPrompt,
712
772
  movePrompt,
713
773
  listVersions,
714
774
  listPrompts,
@@ -724,6 +784,7 @@ export {
724
784
  generateSlug,
725
785
  generatePromptId,
726
786
  findSimilar,
787
+ findDuplicates,
727
788
  extractVariables,
728
789
  extractVariableInfo,
729
790
  exportToJson,
@@ -0,0 +1,7 @@
1
+ import type { Prompt } from "../types/index.js";
2
+ export interface DuplicateMatch {
3
+ prompt: Prompt;
4
+ score: number;
5
+ }
6
+ export declare function findDuplicates(body: string, threshold?: number, excludeSlug?: string): DuplicateMatch[];
7
+ //# sourceMappingURL=duplicates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duplicates.d.ts","sourceRoot":"","sources":["../../src/lib/duplicates.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAA;AAuB/C,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;CACd;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,SAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,cAAc,EAAE,CAapG"}
@@ -0,0 +1,16 @@
1
+ import type { Prompt } from "../types/index.js";
2
+ export type LintSeverity = "error" | "warn" | "info";
3
+ export interface LintIssue {
4
+ prompt_id: string;
5
+ slug: string;
6
+ severity: LintSeverity;
7
+ rule: string;
8
+ message: string;
9
+ }
10
+ export interface LintResult {
11
+ prompt: Prompt;
12
+ issues: LintIssue[];
13
+ }
14
+ export declare function lintPrompt(p: Prompt): LintIssue[];
15
+ export declare function lintAll(prompts: Prompt[]): LintResult[];
16
+ //# sourceMappingURL=lint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lint.d.ts","sourceRoot":"","sources":["../../src/lib/lint.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAA;AAE/C,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAA;AAEpD,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,YAAY,CAAA;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,SAAS,EAAE,CAAA;CACpB;AAED,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,EAAE,CA2CjD;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,UAAU,EAAE,CAIvD"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Optional open-mementos integration.
3
+ * Saves a memory when a prompt is used/rendered, if PROMPTS_SAVE_MEMENTOS=1
4
+ * and @hasna/mementos is installed. Gracefully no-ops if not available.
5
+ */
6
+ export interface MementoSaveOptions {
7
+ slug: string;
8
+ body: string;
9
+ rendered?: string;
10
+ agentId?: string;
11
+ }
12
+ export declare function maybeSaveMemento(opts: MementoSaveOptions): Promise<void>;
13
+ //# sourceMappingURL=mementos.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mementos.d.ts","sourceRoot":"","sources":["../../src/lib/mementos.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B9E"}
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/lib/search.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAsCxE,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,IAAI,CAAC,iBAAiB,EAAE,GAAG,CAAM,GACxC,YAAY,EAAE,CAoEhB;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,SAAI,GAAG,YAAY,EAAE,CAmCvE"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/lib/search.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAuCxE,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,IAAI,CAAC,iBAAiB,EAAE,GAAG,CAAM,GACxC,YAAY,EAAE,CAoEhB;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,SAAI,GAAG,YAAY,EAAE,CAmCvE"}
package/dist/mcp/index.js CHANGED
@@ -10,6 +10,7 @@ var __export = (target, all) => {
10
10
  set: (newValue) => all[name] = () => newValue
11
11
  });
12
12
  };
13
+ var __require = import.meta.require;
13
14
 
14
15
  // src/mcp/index.ts
15
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -4098,6 +4099,10 @@ function runMigrations(db) {
4098
4099
  CREATE INDEX IF NOT EXISTS idx_versions_prompt_id ON prompt_versions(prompt_id);
4099
4100
  `
4100
4101
  },
4102
+ {
4103
+ name: "003_pinned",
4104
+ sql: `ALTER TABLE prompts ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;`
4105
+ },
4101
4106
  {
4102
4107
  name: "002_fts5",
4103
4108
  sql: `
@@ -4151,6 +4156,15 @@ function resolvePrompt(db, idOrSlug) {
4151
4156
  const byPrefix = db.query("SELECT id FROM prompts WHERE id LIKE ? LIMIT 2").all(`${idOrSlug}%`);
4152
4157
  if (byPrefix.length === 1 && byPrefix[0])
4153
4158
  return byPrefix[0].id;
4159
+ const bySlugPrefix = db.query("SELECT id FROM prompts WHERE slug LIKE ? LIMIT 2").all(`${idOrSlug}%`);
4160
+ if (bySlugPrefix.length === 1 && bySlugPrefix[0])
4161
+ return bySlugPrefix[0].id;
4162
+ const bySlugSub = db.query("SELECT id FROM prompts WHERE slug LIKE ? LIMIT 2").all(`%${idOrSlug}%`);
4163
+ if (bySlugSub.length === 1 && bySlugSub[0])
4164
+ return bySlugSub[0].id;
4165
+ const byTitle = db.query("SELECT id FROM prompts WHERE lower(title) LIKE ? LIMIT 2").all(`%${idOrSlug.toLowerCase()}%`);
4166
+ if (byTitle.length === 1 && byTitle[0])
4167
+ return byTitle[0].id;
4154
4168
  return null;
4155
4169
  }
4156
4170
 
@@ -4244,6 +4258,36 @@ function movePrompt(promptIdOrSlug, targetCollection) {
4244
4258
  ]);
4245
4259
  }
4246
4260
 
4261
+ // src/lib/duplicates.ts
4262
+ function tokenize(text) {
4263
+ return new Set(text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2));
4264
+ }
4265
+ function similarity(a, b) {
4266
+ const ta = tokenize(a);
4267
+ const tb = tokenize(b);
4268
+ if (ta.size === 0 || tb.size === 0)
4269
+ return 0;
4270
+ let shared = 0;
4271
+ for (const word of ta) {
4272
+ if (tb.has(word))
4273
+ shared++;
4274
+ }
4275
+ return shared / Math.max(ta.size, tb.size);
4276
+ }
4277
+ function findDuplicates(body, threshold = 0.8, excludeSlug) {
4278
+ const all = listPrompts({ limit: 1e4 });
4279
+ const matches = [];
4280
+ for (const p of all) {
4281
+ if (excludeSlug && p.slug === excludeSlug)
4282
+ continue;
4283
+ const score = similarity(body, p.body);
4284
+ if (score >= threshold) {
4285
+ matches.push({ prompt: p, score });
4286
+ }
4287
+ }
4288
+ return matches.sort((a, b) => b.score - a.score);
4289
+ }
4290
+
4247
4291
  // src/lib/template.ts
4248
4292
  var VAR_PATTERN = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:\|\s*(.*?)\s*)?\}\}/g;
4249
4293
  function extractVariables(body) {
@@ -4330,6 +4374,7 @@ function rowToPrompt(row) {
4330
4374
  collection: row["collection"],
4331
4375
  tags: JSON.parse(row["tags"] || "[]"),
4332
4376
  variables: JSON.parse(row["variables"] || "[]"),
4377
+ pinned: Boolean(row["pinned"]),
4333
4378
  is_template: Boolean(row["is_template"]),
4334
4379
  source: row["source"],
4335
4380
  version: row["version"],
@@ -4404,7 +4449,7 @@ function listPrompts(filter = {}) {
4404
4449
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
4405
4450
  const limit = filter.limit ?? 100;
4406
4451
  const offset = filter.offset ?? 0;
4407
- const rows = db.query(`SELECT * FROM prompts ${where} ORDER BY use_count DESC, updated_at DESC LIMIT ? OFFSET ?`).all([...params, limit, offset]);
4452
+ const rows = db.query(`SELECT * FROM prompts ${where} ORDER BY pinned DESC, use_count DESC, updated_at DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
4408
4453
  return rows.map(rowToPrompt);
4409
4454
  }
4410
4455
  function updatePrompt(idOrSlug, input) {
@@ -4454,7 +4499,13 @@ function usePrompt(idOrSlug) {
4454
4499
  db.run("UPDATE prompts SET use_count = use_count + 1, last_used_at = datetime('now') WHERE id = ?", [prompt.id]);
4455
4500
  return requirePrompt(prompt.id);
4456
4501
  }
4457
- function upsertPrompt(input) {
4502
+ function pinPrompt(idOrSlug, pinned) {
4503
+ const db = getDatabase();
4504
+ const prompt = requirePrompt(idOrSlug);
4505
+ db.run("UPDATE prompts SET pinned = ?, updated_at = datetime('now') WHERE id = ?", [pinned ? 1 : 0, prompt.id]);
4506
+ return requirePrompt(prompt.id);
4507
+ }
4508
+ function upsertPrompt(input, force = false) {
4458
4509
  const db = getDatabase();
4459
4510
  const slug = input.slug || generateSlug(input.title);
4460
4511
  const existing = db.query("SELECT id FROM prompts WHERE slug = ?").get(slug);
@@ -4469,8 +4520,16 @@ function upsertPrompt(input) {
4469
4520
  });
4470
4521
  return { prompt: prompt2, created: false };
4471
4522
  }
4523
+ let duplicate_warning;
4524
+ if (!force && input.body) {
4525
+ const dupes = findDuplicates(input.body, 0.8, slug);
4526
+ if (dupes.length > 0) {
4527
+ const top = dupes[0];
4528
+ duplicate_warning = `Similar prompt already exists: "${top.prompt.slug}" (${Math.round(top.score * 100)}% match). Use --force to save anyway.`;
4529
+ }
4530
+ }
4472
4531
  const prompt = createPrompt({ ...input, slug });
4473
- return { prompt, created: true };
4532
+ return { prompt, created: true, duplicate_warning };
4474
4533
  }
4475
4534
  function getPromptStats() {
4476
4535
  const db = getDatabase();
@@ -4561,6 +4620,7 @@ function rowToSearchResult(row, snippet) {
4561
4620
  collection: row["collection"],
4562
4621
  tags: JSON.parse(row["tags"] || "[]"),
4563
4622
  variables: JSON.parse(row["variables"] || "[]"),
4623
+ pinned: Boolean(row["pinned"]),
4564
4624
  is_template: Boolean(row["is_template"]),
4565
4625
  source: row["source"],
4566
4626
  version: row["version"],
@@ -4615,7 +4675,7 @@ function searchPrompts(query, filter = {}) {
4615
4675
  WHERE prompts_fts MATCH ?
4616
4676
  ${where}
4617
4677
  ORDER BY bm25(prompts_fts)
4618
- LIMIT ? OFFSET ?`).all([ftsQuery, ...params, limit, offset]);
4678
+ LIMIT ? OFFSET ?`).all(ftsQuery, ...params, limit, offset);
4619
4679
  return rows2.map((r) => rowToSearchResult(r, r["snippet"]));
4620
4680
  } catch {}
4621
4681
  }
@@ -4623,7 +4683,7 @@ function searchPrompts(query, filter = {}) {
4623
4683
  const rows = db.query(`SELECT *, 1 as score FROM prompts
4624
4684
  WHERE (name LIKE ? OR slug LIKE ? OR title LIKE ? OR body LIKE ? OR description LIKE ? OR tags LIKE ?)
4625
4685
  ORDER BY use_count DESC, updated_at DESC
4626
- LIMIT ? OFFSET ?`).all([like, like, like, like, like, like, filter.limit ?? 50, filter.offset ?? 0]);
4686
+ LIMIT ? OFFSET ?`).all(like, like, like, like, like, like, filter.limit ?? 50, filter.offset ?? 0);
4627
4687
  return rows.map((r) => rowToSearchResult(r));
4628
4688
  }
4629
4689
  function findSimilar(promptId, limit = 5) {
@@ -4634,10 +4694,10 @@ function findSimilar(promptId, limit = 5) {
4634
4694
  const tags = JSON.parse(prompt["tags"] || "[]");
4635
4695
  const collection = prompt["collection"];
4636
4696
  if (tags.length === 0) {
4637
- const rows = db.query("SELECT *, 1 as score FROM prompts WHERE collection = ? AND id != ? ORDER BY use_count DESC LIMIT ?").all([collection, promptId, limit]);
4697
+ const rows = db.query("SELECT *, 1 as score FROM prompts WHERE collection = ? AND id != ? ORDER BY use_count DESC LIMIT ?").all(collection, promptId, limit);
4638
4698
  return rows.map((r) => rowToSearchResult(r));
4639
4699
  }
4640
- const allRows = db.query("SELECT * FROM prompts WHERE id != ?").all([promptId]);
4700
+ const allRows = db.query("SELECT * FROM prompts WHERE id != ?").all(promptId);
4641
4701
  const scored = allRows.map((row) => {
4642
4702
  const rowTags = JSON.parse(row["tags"] || "[]");
4643
4703
  const overlap = rowTags.filter((t) => tags.includes(t)).length;
@@ -4680,6 +4740,64 @@ function exportToJson(collection) {
4680
4740
  return { prompts, exported_at: new Date().toISOString(), collection };
4681
4741
  }
4682
4742
 
4743
+ // src/lib/mementos.ts
4744
+ async function maybeSaveMemento(opts) {
4745
+ if (process.env["PROMPTS_SAVE_MEMENTOS"] !== "1")
4746
+ return;
4747
+ try {
4748
+ const mod = await import("@hasna/mementos").catch(() => null);
4749
+ if (!mod)
4750
+ return;
4751
+ const key = `prompts/used/${opts.slug}`;
4752
+ const value = opts.rendered ?? opts.body;
4753
+ const save = mod["createMemory"] ?? mod["saveMemory"];
4754
+ if (typeof save !== "function")
4755
+ return;
4756
+ await save({
4757
+ key,
4758
+ value,
4759
+ scope: "private",
4760
+ agent_id: opts.agentId,
4761
+ tags: ["prompts", opts.slug],
4762
+ summary: `Used prompt: ${opts.slug}`
4763
+ });
4764
+ } catch {}
4765
+ }
4766
+
4767
+ // src/lib/lint.ts
4768
+ function lintPrompt(p) {
4769
+ const issues = [];
4770
+ const issue = (severity, rule, message) => ({
4771
+ prompt_id: p.id,
4772
+ slug: p.slug,
4773
+ severity,
4774
+ rule,
4775
+ message
4776
+ });
4777
+ if (!p.description) {
4778
+ issues.push(issue("warn", "missing-description", "No description provided"));
4779
+ }
4780
+ if (p.body.trim().length < 10) {
4781
+ issues.push(issue("error", "body-too-short", `Body is only ${p.body.trim().length} characters`));
4782
+ }
4783
+ if (p.tags.length === 0) {
4784
+ issues.push(issue("info", "no-tags", "No tags \u2014 prompt will be harder to discover"));
4785
+ }
4786
+ if (p.is_template) {
4787
+ const undocumented = p.variables.filter((v) => !v.description || v.description.trim() === "");
4788
+ if (undocumented.length > 0) {
4789
+ issues.push(issue("warn", "undocumented-vars", `Template variables without description: ${undocumented.map((v) => v.name).join(", ")}`));
4790
+ }
4791
+ }
4792
+ if (p.collection === "default" && p.use_count === 0) {
4793
+ issues.push(issue("info", "uncollected", "In default collection and never used \u2014 consider organizing"));
4794
+ }
4795
+ return issues;
4796
+ }
4797
+ function lintAll(prompts) {
4798
+ return prompts.map((p) => ({ prompt: p, issues: lintPrompt(p) })).filter((r) => r.issues.length > 0);
4799
+ }
4800
+
4683
4801
  // src/mcp/index.ts
4684
4802
  var server = new McpServer({ name: "open-prompts", version: "0.1.0" });
4685
4803
  function ok(data) {
@@ -4698,12 +4816,14 @@ server.registerTool("prompts_save", {
4698
4816
  collection: exports_external.string().optional().describe("Collection/namespace (default: 'default')"),
4699
4817
  tags: exports_external.array(exports_external.string()).optional().describe("Tags for filtering and search"),
4700
4818
  source: exports_external.enum(["manual", "ai-session", "imported"]).optional().describe("Where this prompt came from"),
4701
- changed_by: exports_external.string().optional().describe("Agent name making this change")
4819
+ changed_by: exports_external.string().optional().describe("Agent name making this change"),
4820
+ force: exports_external.boolean().optional().describe("Save even if a similar prompt already exists")
4702
4821
  }
4703
4822
  }, async (args) => {
4704
4823
  try {
4705
- const { prompt, created } = await upsertPrompt(args);
4706
- return ok({ ...prompt, _created: created });
4824
+ const { force, ...input } = args;
4825
+ const { prompt, created, duplicate_warning } = upsertPrompt(input, force ?? false);
4826
+ return ok({ ...prompt, _created: created, _duplicate_warning: duplicate_warning ?? null });
4707
4827
  } catch (e) {
4708
4828
  return err(e instanceof Error ? e.message : String(e));
4709
4829
  }
@@ -4741,10 +4861,14 @@ server.registerTool("prompts_delete", {
4741
4861
  });
4742
4862
  server.registerTool("prompts_use", {
4743
4863
  description: "Get a prompt's body and increment its use counter. This is the primary way to retrieve a prompt for actual use.",
4744
- inputSchema: { id: exports_external.string().describe("Prompt ID or slug") }
4745
- }, async ({ id }) => {
4864
+ inputSchema: {
4865
+ id: exports_external.string().describe("Prompt ID or slug"),
4866
+ agent: exports_external.string().optional().describe("Agent ID for mementos integration")
4867
+ }
4868
+ }, async ({ id, agent }) => {
4746
4869
  try {
4747
4870
  const prompt = usePrompt(id);
4871
+ await maybeSaveMemento({ slug: prompt.slug, body: prompt.body, agentId: agent });
4748
4872
  return ok({ body: prompt.body, prompt });
4749
4873
  } catch (e) {
4750
4874
  return err(e instanceof Error ? e.message : String(e));
@@ -4754,12 +4878,14 @@ server.registerTool("prompts_render", {
4754
4878
  description: "Render a template prompt by filling in {{variables}}. Returns rendered body plus info on missing/defaulted vars.",
4755
4879
  inputSchema: {
4756
4880
  id: exports_external.string().describe("Prompt ID or slug"),
4757
- vars: exports_external.record(exports_external.string()).describe("Variable values as key-value pairs")
4881
+ vars: exports_external.record(exports_external.string()).describe("Variable values as key-value pairs"),
4882
+ agent: exports_external.string().optional().describe("Agent ID for mementos integration")
4758
4883
  }
4759
- }, async ({ id, vars }) => {
4884
+ }, async ({ id, vars, agent }) => {
4760
4885
  try {
4761
4886
  const prompt = usePrompt(id);
4762
4887
  const result = renderTemplate(prompt.body, vars);
4888
+ await maybeSaveMemento({ slug: prompt.slug, body: prompt.body, rendered: result.rendered, agentId: agent });
4763
4889
  return ok(result);
4764
4890
  } catch (e) {
4765
4891
  return err(e instanceof Error ? e.message : String(e));
@@ -4922,6 +5048,86 @@ server.registerTool("prompts_ensure_collection", {
4922
5048
  description: exports_external.string().optional()
4923
5049
  }
4924
5050
  }, async ({ name, description }) => ok(ensureCollection(name, description)));
5051
+ server.registerTool("prompts_save_from_session", {
5052
+ description: "Minimal frictionless save for AI agents mid-conversation. The agent is expected to derive title, slug, and tags from the body before calling this. Automatically sets source=ai-session. Perfect for 'save this as a reusable prompt' moments.",
5053
+ inputSchema: {
5054
+ title: exports_external.string().describe("A short descriptive title for this prompt"),
5055
+ body: exports_external.string().describe("The prompt content to save"),
5056
+ slug: exports_external.string().optional().describe("URL-friendly identifier (auto-generated from title if omitted)"),
5057
+ tags: exports_external.array(exports_external.string()).optional().describe("Relevant tags extracted from the prompt context"),
5058
+ collection: exports_external.string().optional().describe("Collection to save into (default: 'sessions')"),
5059
+ description: exports_external.string().optional().describe("One-line description of what this prompt does"),
5060
+ agent: exports_external.string().optional().describe("Agent name saving this prompt")
5061
+ }
5062
+ }, async ({ title, body, slug, tags, collection, description, agent }) => {
5063
+ try {
5064
+ const { prompt, created } = upsertPrompt({
5065
+ title,
5066
+ body,
5067
+ slug,
5068
+ tags,
5069
+ collection: collection ?? "sessions",
5070
+ description,
5071
+ source: "ai-session",
5072
+ changed_by: agent
5073
+ });
5074
+ return ok({ ...prompt, _created: created, _tip: created ? `Saved as "${prompt.slug}". Use prompts_use("${prompt.slug}") to retrieve it.` : `Updated existing prompt "${prompt.slug}".` });
5075
+ } catch (e) {
5076
+ return err(e instanceof Error ? e.message : String(e));
5077
+ }
5078
+ });
5079
+ server.registerTool("prompts_pin", {
5080
+ description: "Pin a prompt so it always appears first in lists.",
5081
+ inputSchema: { id: exports_external.string() }
5082
+ }, async ({ id }) => {
5083
+ try {
5084
+ return ok(pinPrompt(id, true));
5085
+ } catch (e) {
5086
+ return err(e instanceof Error ? e.message : String(e));
5087
+ }
5088
+ });
5089
+ server.registerTool("prompts_unpin", {
5090
+ description: "Unpin a previously pinned prompt.",
5091
+ inputSchema: { id: exports_external.string() }
5092
+ }, async ({ id }) => {
5093
+ try {
5094
+ return ok(pinPrompt(id, false));
5095
+ } catch (e) {
5096
+ return err(e instanceof Error ? e.message : String(e));
5097
+ }
5098
+ });
5099
+ server.registerTool("prompts_recent", {
5100
+ description: "Get recently used prompts, ordered by last_used_at descending.",
5101
+ inputSchema: { limit: exports_external.number().optional().default(10) }
5102
+ }, async ({ limit }) => {
5103
+ const prompts = listPrompts({ limit: 500 }).filter((p) => p.last_used_at !== null).sort((a, b) => (b.last_used_at ?? "").localeCompare(a.last_used_at ?? "")).slice(0, limit);
5104
+ return ok(prompts);
5105
+ });
5106
+ server.registerTool("prompts_lint", {
5107
+ description: "Check prompt quality: missing descriptions, undocumented template vars, short bodies, no tags.",
5108
+ inputSchema: { collection: exports_external.string().optional() }
5109
+ }, async ({ collection }) => {
5110
+ const prompts = listPrompts({ collection, limit: 1e4 });
5111
+ const results = lintAll(prompts);
5112
+ const summary = {
5113
+ total_checked: prompts.length,
5114
+ prompts_with_issues: results.length,
5115
+ errors: results.flatMap((r) => r.issues).filter((i) => i.severity === "error").length,
5116
+ warnings: results.flatMap((r) => r.issues).filter((i) => i.severity === "warn").length,
5117
+ info: results.flatMap((r) => r.issues).filter((i) => i.severity === "info").length,
5118
+ results
5119
+ };
5120
+ return ok(summary);
5121
+ });
5122
+ server.registerTool("prompts_stale", {
5123
+ description: "List prompts not used in N days. Useful for library hygiene.",
5124
+ inputSchema: { days: exports_external.number().optional().default(30).describe("Inactivity threshold in days") }
5125
+ }, async ({ days }) => {
5126
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
5127
+ const all = listPrompts({ limit: 1e4 });
5128
+ const stale = all.filter((p) => p.last_used_at === null || p.last_used_at < cutoff).sort((a, b) => (a.last_used_at ?? "").localeCompare(b.last_used_at ?? ""));
5129
+ return ok({ stale, count: stale.length, threshold_days: days });
5130
+ });
4925
5131
  server.registerTool("prompts_stats", {
4926
5132
  description: "Get usage statistics: most used prompts, recently used, counts by collection and source.",
4927
5133
  inputSchema: {}