@hasna/prompts 0.1.0 → 0.2.1

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
 
@@ -179,25 +192,24 @@ function uniqueSlug(baseSlug) {
179
192
  }
180
193
  return slug;
181
194
  }
195
+ var CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
196
+ function nanoid(len) {
197
+ let id = "";
198
+ for (let i = 0;i < len; i++) {
199
+ id += CHARS[Math.floor(Math.random() * CHARS.length)];
200
+ }
201
+ return id;
202
+ }
182
203
  function generatePromptId() {
183
204
  const db = getDatabase();
184
- const row = db.query("SELECT id FROM prompts ORDER BY rowid DESC LIMIT 1").get();
185
- let next = 1;
186
- if (row) {
187
- const match = row.id.match(/PRMT-(\d+)/);
188
- if (match && match[1]) {
189
- next = parseInt(match[1], 10) + 1;
190
- }
191
- }
192
- return `PRMT-${String(next).padStart(5, "0")}`;
205
+ let id;
206
+ do {
207
+ id = `prmt-${nanoid(8)}`;
208
+ } while (db.query("SELECT 1 FROM prompts WHERE id = ?").get(id));
209
+ return id;
193
210
  }
194
211
  function generateId(prefix) {
195
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
196
- let id = prefix + "-";
197
- for (let i = 0;i < 8; i++) {
198
- id += chars[Math.floor(Math.random() * chars.length)];
199
- }
200
- return id;
212
+ return `${prefix}-${nanoid(8)}`;
201
213
  }
202
214
 
203
215
  // src/db/collections.ts
@@ -255,6 +267,36 @@ function movePrompt(promptIdOrSlug, targetCollection) {
255
267
  ]);
256
268
  }
257
269
 
270
+ // src/lib/duplicates.ts
271
+ function tokenize(text) {
272
+ return new Set(text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2));
273
+ }
274
+ function similarity(a, b) {
275
+ const ta = tokenize(a);
276
+ const tb = tokenize(b);
277
+ if (ta.size === 0 || tb.size === 0)
278
+ return 0;
279
+ let shared = 0;
280
+ for (const word of ta) {
281
+ if (tb.has(word))
282
+ shared++;
283
+ }
284
+ return shared / Math.max(ta.size, tb.size);
285
+ }
286
+ function findDuplicates(body, threshold = 0.8, excludeSlug) {
287
+ const all = listPrompts({ limit: 1e4 });
288
+ const matches = [];
289
+ for (const p of all) {
290
+ if (excludeSlug && p.slug === excludeSlug)
291
+ continue;
292
+ const score = similarity(body, p.body);
293
+ if (score >= threshold) {
294
+ matches.push({ prompt: p, score });
295
+ }
296
+ }
297
+ return matches.sort((a, b) => b.score - a.score);
298
+ }
299
+
258
300
  // src/lib/template.ts
259
301
  var VAR_PATTERN = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:\|\s*(.*?)\s*)?\}\}/g;
260
302
  function extractVariables(body) {
@@ -348,6 +390,7 @@ function rowToPrompt(row) {
348
390
  collection: row["collection"],
349
391
  tags: JSON.parse(row["tags"] || "[]"),
350
392
  variables: JSON.parse(row["variables"] || "[]"),
393
+ pinned: Boolean(row["pinned"]),
351
394
  is_template: Boolean(row["is_template"]),
352
395
  source: row["source"],
353
396
  version: row["version"],
@@ -422,7 +465,7 @@ function listPrompts(filter = {}) {
422
465
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
423
466
  const limit = filter.limit ?? 100;
424
467
  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);
468
+ 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
469
  return rows.map(rowToPrompt);
427
470
  }
428
471
  function updatePrompt(idOrSlug, input) {
@@ -472,7 +515,13 @@ function usePrompt(idOrSlug) {
472
515
  db.run("UPDATE prompts SET use_count = use_count + 1, last_used_at = datetime('now') WHERE id = ?", [prompt.id]);
473
516
  return requirePrompt(prompt.id);
474
517
  }
475
- function upsertPrompt(input) {
518
+ function pinPrompt(idOrSlug, pinned) {
519
+ const db = getDatabase();
520
+ const prompt = requirePrompt(idOrSlug);
521
+ db.run("UPDATE prompts SET pinned = ?, updated_at = datetime('now') WHERE id = ?", [pinned ? 1 : 0, prompt.id]);
522
+ return requirePrompt(prompt.id);
523
+ }
524
+ function upsertPrompt(input, force = false) {
476
525
  const db = getDatabase();
477
526
  const slug = input.slug || generateSlug(input.title);
478
527
  const existing = db.query("SELECT id FROM prompts WHERE slug = ?").get(slug);
@@ -487,8 +536,16 @@ function upsertPrompt(input) {
487
536
  });
488
537
  return { prompt: prompt2, created: false };
489
538
  }
539
+ let duplicate_warning;
540
+ if (!force && input.body) {
541
+ const dupes = findDuplicates(input.body, 0.8, slug);
542
+ if (dupes.length > 0) {
543
+ const top = dupes[0];
544
+ duplicate_warning = `Similar prompt already exists: "${top.prompt.slug}" (${Math.round(top.score * 100)}% match). Use --force to save anyway.`;
545
+ }
546
+ }
490
547
  const prompt = createPrompt({ ...input, slug });
491
- return { prompt, created: true };
548
+ return { prompt, created: true, duplicate_warning };
492
549
  }
493
550
  function getPromptStats() {
494
551
  const db = getDatabase();
@@ -581,6 +638,7 @@ function rowToSearchResult(row, snippet) {
581
638
  collection: row["collection"],
582
639
  tags: JSON.parse(row["tags"] || "[]"),
583
640
  variables: JSON.parse(row["variables"] || "[]"),
641
+ pinned: Boolean(row["pinned"]),
584
642
  is_template: Boolean(row["is_template"]),
585
643
  source: row["source"],
586
644
  version: row["version"],
@@ -709,6 +767,7 @@ export {
709
767
  requirePrompt,
710
768
  renderTemplate,
711
769
  registerAgent,
770
+ pinPrompt,
712
771
  movePrompt,
713
772
  listVersions,
714
773
  listPrompts,
@@ -724,6 +783,7 @@ export {
724
783
  generateSlug,
725
784
  generatePromptId,
726
785
  findSimilar,
786
+ findDuplicates,
727
787
  extractVariables,
728
788
  extractVariableInfo,
729
789
  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"}
@@ -1 +1 @@
1
- {"version":3,"file":"ids.d.ts","sourceRoot":"","sources":["../../src/lib/ids.ts"],"names":[],"mappings":"AAEA,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQlD;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CASnD;AAED,wBAAgB,gBAAgB,IAAI,MAAM,CAezC;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAOjD"}
1
+ {"version":3,"file":"ids.d.ts","sourceRoot":"","sources":["../../src/lib/ids.ts"],"names":[],"mappings":"AAEA,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQlD;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CASnD;AAYD,wBAAgB,gBAAgB,IAAI,MAAM,CAOzC;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEjD"}
@@ -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
 
@@ -4168,25 +4182,24 @@ function uniqueSlug(baseSlug) {
4168
4182
  }
4169
4183
  return slug;
4170
4184
  }
4185
+ var CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
4186
+ function nanoid(len) {
4187
+ let id = "";
4188
+ for (let i = 0;i < len; i++) {
4189
+ id += CHARS[Math.floor(Math.random() * CHARS.length)];
4190
+ }
4191
+ return id;
4192
+ }
4171
4193
  function generatePromptId() {
4172
4194
  const db = getDatabase();
4173
- const row = db.query("SELECT id FROM prompts ORDER BY rowid DESC LIMIT 1").get();
4174
- let next = 1;
4175
- if (row) {
4176
- const match = row.id.match(/PRMT-(\d+)/);
4177
- if (match && match[1]) {
4178
- next = parseInt(match[1], 10) + 1;
4179
- }
4180
- }
4181
- return `PRMT-${String(next).padStart(5, "0")}`;
4195
+ let id;
4196
+ do {
4197
+ id = `prmt-${nanoid(8)}`;
4198
+ } while (db.query("SELECT 1 FROM prompts WHERE id = ?").get(id));
4199
+ return id;
4182
4200
  }
4183
4201
  function generateId(prefix) {
4184
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
4185
- let id = prefix + "-";
4186
- for (let i = 0;i < 8; i++) {
4187
- id += chars[Math.floor(Math.random() * chars.length)];
4188
- }
4189
- return id;
4202
+ return `${prefix}-${nanoid(8)}`;
4190
4203
  }
4191
4204
 
4192
4205
  // src/db/collections.ts
@@ -4244,6 +4257,36 @@ function movePrompt(promptIdOrSlug, targetCollection) {
4244
4257
  ]);
4245
4258
  }
4246
4259
 
4260
+ // src/lib/duplicates.ts
4261
+ function tokenize(text) {
4262
+ return new Set(text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2));
4263
+ }
4264
+ function similarity(a, b) {
4265
+ const ta = tokenize(a);
4266
+ const tb = tokenize(b);
4267
+ if (ta.size === 0 || tb.size === 0)
4268
+ return 0;
4269
+ let shared = 0;
4270
+ for (const word of ta) {
4271
+ if (tb.has(word))
4272
+ shared++;
4273
+ }
4274
+ return shared / Math.max(ta.size, tb.size);
4275
+ }
4276
+ function findDuplicates(body, threshold = 0.8, excludeSlug) {
4277
+ const all = listPrompts({ limit: 1e4 });
4278
+ const matches = [];
4279
+ for (const p of all) {
4280
+ if (excludeSlug && p.slug === excludeSlug)
4281
+ continue;
4282
+ const score = similarity(body, p.body);
4283
+ if (score >= threshold) {
4284
+ matches.push({ prompt: p, score });
4285
+ }
4286
+ }
4287
+ return matches.sort((a, b) => b.score - a.score);
4288
+ }
4289
+
4247
4290
  // src/lib/template.ts
4248
4291
  var VAR_PATTERN = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:\|\s*(.*?)\s*)?\}\}/g;
4249
4292
  function extractVariables(body) {
@@ -4330,6 +4373,7 @@ function rowToPrompt(row) {
4330
4373
  collection: row["collection"],
4331
4374
  tags: JSON.parse(row["tags"] || "[]"),
4332
4375
  variables: JSON.parse(row["variables"] || "[]"),
4376
+ pinned: Boolean(row["pinned"]),
4333
4377
  is_template: Boolean(row["is_template"]),
4334
4378
  source: row["source"],
4335
4379
  version: row["version"],
@@ -4404,7 +4448,7 @@ function listPrompts(filter = {}) {
4404
4448
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
4405
4449
  const limit = filter.limit ?? 100;
4406
4450
  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]);
4451
+ 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
4452
  return rows.map(rowToPrompt);
4409
4453
  }
4410
4454
  function updatePrompt(idOrSlug, input) {
@@ -4454,7 +4498,13 @@ function usePrompt(idOrSlug) {
4454
4498
  db.run("UPDATE prompts SET use_count = use_count + 1, last_used_at = datetime('now') WHERE id = ?", [prompt.id]);
4455
4499
  return requirePrompt(prompt.id);
4456
4500
  }
4457
- function upsertPrompt(input) {
4501
+ function pinPrompt(idOrSlug, pinned) {
4502
+ const db = getDatabase();
4503
+ const prompt = requirePrompt(idOrSlug);
4504
+ db.run("UPDATE prompts SET pinned = ?, updated_at = datetime('now') WHERE id = ?", [pinned ? 1 : 0, prompt.id]);
4505
+ return requirePrompt(prompt.id);
4506
+ }
4507
+ function upsertPrompt(input, force = false) {
4458
4508
  const db = getDatabase();
4459
4509
  const slug = input.slug || generateSlug(input.title);
4460
4510
  const existing = db.query("SELECT id FROM prompts WHERE slug = ?").get(slug);
@@ -4469,8 +4519,16 @@ function upsertPrompt(input) {
4469
4519
  });
4470
4520
  return { prompt: prompt2, created: false };
4471
4521
  }
4522
+ let duplicate_warning;
4523
+ if (!force && input.body) {
4524
+ const dupes = findDuplicates(input.body, 0.8, slug);
4525
+ if (dupes.length > 0) {
4526
+ const top = dupes[0];
4527
+ duplicate_warning = `Similar prompt already exists: "${top.prompt.slug}" (${Math.round(top.score * 100)}% match). Use --force to save anyway.`;
4528
+ }
4529
+ }
4472
4530
  const prompt = createPrompt({ ...input, slug });
4473
- return { prompt, created: true };
4531
+ return { prompt, created: true, duplicate_warning };
4474
4532
  }
4475
4533
  function getPromptStats() {
4476
4534
  const db = getDatabase();
@@ -4561,6 +4619,7 @@ function rowToSearchResult(row, snippet) {
4561
4619
  collection: row["collection"],
4562
4620
  tags: JSON.parse(row["tags"] || "[]"),
4563
4621
  variables: JSON.parse(row["variables"] || "[]"),
4622
+ pinned: Boolean(row["pinned"]),
4564
4623
  is_template: Boolean(row["is_template"]),
4565
4624
  source: row["source"],
4566
4625
  version: row["version"],
@@ -4615,7 +4674,7 @@ function searchPrompts(query, filter = {}) {
4615
4674
  WHERE prompts_fts MATCH ?
4616
4675
  ${where}
4617
4676
  ORDER BY bm25(prompts_fts)
4618
- LIMIT ? OFFSET ?`).all([ftsQuery, ...params, limit, offset]);
4677
+ LIMIT ? OFFSET ?`).all(ftsQuery, ...params, limit, offset);
4619
4678
  return rows2.map((r) => rowToSearchResult(r, r["snippet"]));
4620
4679
  } catch {}
4621
4680
  }
@@ -4623,7 +4682,7 @@ function searchPrompts(query, filter = {}) {
4623
4682
  const rows = db.query(`SELECT *, 1 as score FROM prompts
4624
4683
  WHERE (name LIKE ? OR slug LIKE ? OR title LIKE ? OR body LIKE ? OR description LIKE ? OR tags LIKE ?)
4625
4684
  ORDER BY use_count DESC, updated_at DESC
4626
- LIMIT ? OFFSET ?`).all([like, like, like, like, like, like, filter.limit ?? 50, filter.offset ?? 0]);
4685
+ LIMIT ? OFFSET ?`).all(like, like, like, like, like, like, filter.limit ?? 50, filter.offset ?? 0);
4627
4686
  return rows.map((r) => rowToSearchResult(r));
4628
4687
  }
4629
4688
  function findSimilar(promptId, limit = 5) {
@@ -4634,10 +4693,10 @@ function findSimilar(promptId, limit = 5) {
4634
4693
  const tags = JSON.parse(prompt["tags"] || "[]");
4635
4694
  const collection = prompt["collection"];
4636
4695
  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]);
4696
+ 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
4697
  return rows.map((r) => rowToSearchResult(r));
4639
4698
  }
4640
- const allRows = db.query("SELECT * FROM prompts WHERE id != ?").all([promptId]);
4699
+ const allRows = db.query("SELECT * FROM prompts WHERE id != ?").all(promptId);
4641
4700
  const scored = allRows.map((row) => {
4642
4701
  const rowTags = JSON.parse(row["tags"] || "[]");
4643
4702
  const overlap = rowTags.filter((t) => tags.includes(t)).length;
@@ -4680,6 +4739,64 @@ function exportToJson(collection) {
4680
4739
  return { prompts, exported_at: new Date().toISOString(), collection };
4681
4740
  }
4682
4741
 
4742
+ // src/lib/mementos.ts
4743
+ async function maybeSaveMemento(opts) {
4744
+ if (process.env["PROMPTS_SAVE_MEMENTOS"] !== "1")
4745
+ return;
4746
+ try {
4747
+ const mod = await import("@hasna/mementos").catch(() => null);
4748
+ if (!mod)
4749
+ return;
4750
+ const key = `prompts/used/${opts.slug}`;
4751
+ const value = opts.rendered ?? opts.body;
4752
+ const save = mod["createMemory"] ?? mod["saveMemory"];
4753
+ if (typeof save !== "function")
4754
+ return;
4755
+ await save({
4756
+ key,
4757
+ value,
4758
+ scope: "private",
4759
+ agent_id: opts.agentId,
4760
+ tags: ["prompts", opts.slug],
4761
+ summary: `Used prompt: ${opts.slug}`
4762
+ });
4763
+ } catch {}
4764
+ }
4765
+
4766
+ // src/lib/lint.ts
4767
+ function lintPrompt(p) {
4768
+ const issues = [];
4769
+ const issue = (severity, rule, message) => ({
4770
+ prompt_id: p.id,
4771
+ slug: p.slug,
4772
+ severity,
4773
+ rule,
4774
+ message
4775
+ });
4776
+ if (!p.description) {
4777
+ issues.push(issue("warn", "missing-description", "No description provided"));
4778
+ }
4779
+ if (p.body.trim().length < 10) {
4780
+ issues.push(issue("error", "body-too-short", `Body is only ${p.body.trim().length} characters`));
4781
+ }
4782
+ if (p.tags.length === 0) {
4783
+ issues.push(issue("info", "no-tags", "No tags \u2014 prompt will be harder to discover"));
4784
+ }
4785
+ if (p.is_template) {
4786
+ const undocumented = p.variables.filter((v) => !v.description || v.description.trim() === "");
4787
+ if (undocumented.length > 0) {
4788
+ issues.push(issue("warn", "undocumented-vars", `Template variables without description: ${undocumented.map((v) => v.name).join(", ")}`));
4789
+ }
4790
+ }
4791
+ if (p.collection === "default" && p.use_count === 0) {
4792
+ issues.push(issue("info", "uncollected", "In default collection and never used \u2014 consider organizing"));
4793
+ }
4794
+ return issues;
4795
+ }
4796
+ function lintAll(prompts) {
4797
+ return prompts.map((p) => ({ prompt: p, issues: lintPrompt(p) })).filter((r) => r.issues.length > 0);
4798
+ }
4799
+
4683
4800
  // src/mcp/index.ts
4684
4801
  var server = new McpServer({ name: "open-prompts", version: "0.1.0" });
4685
4802
  function ok(data) {
@@ -4698,12 +4815,14 @@ server.registerTool("prompts_save", {
4698
4815
  collection: exports_external.string().optional().describe("Collection/namespace (default: 'default')"),
4699
4816
  tags: exports_external.array(exports_external.string()).optional().describe("Tags for filtering and search"),
4700
4817
  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")
4818
+ changed_by: exports_external.string().optional().describe("Agent name making this change"),
4819
+ force: exports_external.boolean().optional().describe("Save even if a similar prompt already exists")
4702
4820
  }
4703
4821
  }, async (args) => {
4704
4822
  try {
4705
- const { prompt, created } = await upsertPrompt(args);
4706
- return ok({ ...prompt, _created: created });
4823
+ const { force, ...input } = args;
4824
+ const { prompt, created, duplicate_warning } = upsertPrompt(input, force ?? false);
4825
+ return ok({ ...prompt, _created: created, _duplicate_warning: duplicate_warning ?? null });
4707
4826
  } catch (e) {
4708
4827
  return err(e instanceof Error ? e.message : String(e));
4709
4828
  }
@@ -4741,10 +4860,14 @@ server.registerTool("prompts_delete", {
4741
4860
  });
4742
4861
  server.registerTool("prompts_use", {
4743
4862
  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 }) => {
4863
+ inputSchema: {
4864
+ id: exports_external.string().describe("Prompt ID or slug"),
4865
+ agent: exports_external.string().optional().describe("Agent ID for mementos integration")
4866
+ }
4867
+ }, async ({ id, agent }) => {
4746
4868
  try {
4747
4869
  const prompt = usePrompt(id);
4870
+ await maybeSaveMemento({ slug: prompt.slug, body: prompt.body, agentId: agent });
4748
4871
  return ok({ body: prompt.body, prompt });
4749
4872
  } catch (e) {
4750
4873
  return err(e instanceof Error ? e.message : String(e));
@@ -4754,12 +4877,14 @@ server.registerTool("prompts_render", {
4754
4877
  description: "Render a template prompt by filling in {{variables}}. Returns rendered body plus info on missing/defaulted vars.",
4755
4878
  inputSchema: {
4756
4879
  id: exports_external.string().describe("Prompt ID or slug"),
4757
- vars: exports_external.record(exports_external.string()).describe("Variable values as key-value pairs")
4880
+ vars: exports_external.record(exports_external.string()).describe("Variable values as key-value pairs"),
4881
+ agent: exports_external.string().optional().describe("Agent ID for mementos integration")
4758
4882
  }
4759
- }, async ({ id, vars }) => {
4883
+ }, async ({ id, vars, agent }) => {
4760
4884
  try {
4761
4885
  const prompt = usePrompt(id);
4762
4886
  const result = renderTemplate(prompt.body, vars);
4887
+ await maybeSaveMemento({ slug: prompt.slug, body: prompt.body, rendered: result.rendered, agentId: agent });
4763
4888
  return ok(result);
4764
4889
  } catch (e) {
4765
4890
  return err(e instanceof Error ? e.message : String(e));
@@ -4922,6 +5047,86 @@ server.registerTool("prompts_ensure_collection", {
4922
5047
  description: exports_external.string().optional()
4923
5048
  }
4924
5049
  }, async ({ name, description }) => ok(ensureCollection(name, description)));
5050
+ server.registerTool("prompts_save_from_session", {
5051
+ 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.",
5052
+ inputSchema: {
5053
+ title: exports_external.string().describe("A short descriptive title for this prompt"),
5054
+ body: exports_external.string().describe("The prompt content to save"),
5055
+ slug: exports_external.string().optional().describe("URL-friendly identifier (auto-generated from title if omitted)"),
5056
+ tags: exports_external.array(exports_external.string()).optional().describe("Relevant tags extracted from the prompt context"),
5057
+ collection: exports_external.string().optional().describe("Collection to save into (default: 'sessions')"),
5058
+ description: exports_external.string().optional().describe("One-line description of what this prompt does"),
5059
+ agent: exports_external.string().optional().describe("Agent name saving this prompt")
5060
+ }
5061
+ }, async ({ title, body, slug, tags, collection, description, agent }) => {
5062
+ try {
5063
+ const { prompt, created } = upsertPrompt({
5064
+ title,
5065
+ body,
5066
+ slug,
5067
+ tags,
5068
+ collection: collection ?? "sessions",
5069
+ description,
5070
+ source: "ai-session",
5071
+ changed_by: agent
5072
+ });
5073
+ return ok({ ...prompt, _created: created, _tip: created ? `Saved as "${prompt.slug}". Use prompts_use("${prompt.slug}") to retrieve it.` : `Updated existing prompt "${prompt.slug}".` });
5074
+ } catch (e) {
5075
+ return err(e instanceof Error ? e.message : String(e));
5076
+ }
5077
+ });
5078
+ server.registerTool("prompts_pin", {
5079
+ description: "Pin a prompt so it always appears first in lists.",
5080
+ inputSchema: { id: exports_external.string() }
5081
+ }, async ({ id }) => {
5082
+ try {
5083
+ return ok(pinPrompt(id, true));
5084
+ } catch (e) {
5085
+ return err(e instanceof Error ? e.message : String(e));
5086
+ }
5087
+ });
5088
+ server.registerTool("prompts_unpin", {
5089
+ description: "Unpin a previously pinned prompt.",
5090
+ inputSchema: { id: exports_external.string() }
5091
+ }, async ({ id }) => {
5092
+ try {
5093
+ return ok(pinPrompt(id, false));
5094
+ } catch (e) {
5095
+ return err(e instanceof Error ? e.message : String(e));
5096
+ }
5097
+ });
5098
+ server.registerTool("prompts_recent", {
5099
+ description: "Get recently used prompts, ordered by last_used_at descending.",
5100
+ inputSchema: { limit: exports_external.number().optional().default(10) }
5101
+ }, async ({ limit }) => {
5102
+ 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);
5103
+ return ok(prompts);
5104
+ });
5105
+ server.registerTool("prompts_lint", {
5106
+ description: "Check prompt quality: missing descriptions, undocumented template vars, short bodies, no tags.",
5107
+ inputSchema: { collection: exports_external.string().optional() }
5108
+ }, async ({ collection }) => {
5109
+ const prompts = listPrompts({ collection, limit: 1e4 });
5110
+ const results = lintAll(prompts);
5111
+ const summary = {
5112
+ total_checked: prompts.length,
5113
+ prompts_with_issues: results.length,
5114
+ errors: results.flatMap((r) => r.issues).filter((i) => i.severity === "error").length,
5115
+ warnings: results.flatMap((r) => r.issues).filter((i) => i.severity === "warn").length,
5116
+ info: results.flatMap((r) => r.issues).filter((i) => i.severity === "info").length,
5117
+ results
5118
+ };
5119
+ return ok(summary);
5120
+ });
5121
+ server.registerTool("prompts_stale", {
5122
+ description: "List prompts not used in N days. Useful for library hygiene.",
5123
+ inputSchema: { days: exports_external.number().optional().default(30).describe("Inactivity threshold in days") }
5124
+ }, async ({ days }) => {
5125
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
5126
+ const all = listPrompts({ limit: 1e4 });
5127
+ 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 ?? ""));
5128
+ return ok({ stale, count: stale.length, threshold_days: days });
5129
+ });
4925
5130
  server.registerTool("prompts_stats", {
4926
5131
  description: "Get usage statistics: most used prompts, recently used, counts by collection and source.",
4927
5132
  inputSchema: {}