feat: add parameterized spell (lens) support to encoding/decoding

Add support for creating parameterized spells that can be applied to
different entities (pubkeys, events, relays). These "lenses" allow
reusable queries with runtime arguments.

Changes:
- Add SpellParameter type with type ($pubkey/$event/$relay) and defaults
- Update CreateSpellOptions and ParsedSpell to include parameter field
- Add parameterType and parameterDefault to LocalSpell DB schema
- Encode parameter as ["l", type, ...defaults] tag in spell events
- Decode parameter configuration from "l" tag
- Add applySpellParameters() utility to substitute placeholders with args
- Support multiple arguments (arrays) for all parameter types

Examples:
- ["l", "$pubkey", "$me"] - pubkey param, defaults to current user
- ["l", "$event"] - event param, no default
- ["l", "$relay"] - relay param, no default

This enables:
- Profile tabs: "Notes", "Articles", "Music" applied to any user
- Event tabs: "Replies", "Reactions", "Zaps" applied to any event
- Relay tabs: "Members", "Popular Posts" applied to any relay
This commit is contained in:
Claude
2026-01-22 12:21:02 +00:00
parent 7838b0ab98
commit f30c414118
3 changed files with 137 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ import type {
EncodedSpell,
ParsedSpell,
SpellEvent,
SpellParameter,
} from "@/types/spell";
import type { NostrFilter } from "@/types/nostr";
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
@@ -72,7 +73,7 @@ export function detectCommandType(command: string): "REQ" | "COUNT" {
* @throws Error if command is invalid or produces empty filter
*/
export function encodeSpell(options: CreateSpellOptions): EncodedSpell {
const { command, name, description, topics, forkedFrom } = options;
const { command, name, description, topics, forkedFrom, parameter } = options;
// Validate command
if (!command || command.trim().length === 0) {
@@ -131,6 +132,15 @@ export function encodeSpell(options: CreateSpellOptions): EncodedSpell {
: `Grimoire ${cmdType} spell`;
tags.push(["alt", altText]);
// Add parameter tag if this is a parameterized spell (lens)
if (parameter) {
const paramTag: [string, string, ...string[]] = ["l", parameter.type];
if (parameter.default && parameter.default.length > 0) {
paramTag.push(...parameter.default);
}
tags.push(paramTag);
}
// Add provenance if forked
if (forkedFrom) {
tags.push(["e", forkedFrom]);
@@ -275,6 +285,19 @@ export function decodeSpell(event: SpellEvent): ParsedSpell {
const topics = tagMap.get("t") || [];
const forkedFrom = tagMap.get("e")?.[0];
// Extract parameter configuration (lens support)
let parameter: ParsedSpell["parameter"];
const lTag = event.tags.find((t) => t[0] === "l");
if (lTag && lTag.length >= 2) {
const [, type, ...defaults] = lTag;
if (type === "$pubkey" || type === "$event" || type === "$relay") {
parameter = {
type,
default: defaults.length > 0 ? defaults : undefined,
};
}
}
// Reconstruct filter from tags
const filter: NostrFilter = {};
@@ -363,6 +386,7 @@ export function decodeSpell(event: SpellEvent): ParsedSpell {
closeOnEose,
topics,
forkedFrom,
parameter,
event,
};
}
@@ -463,3 +487,94 @@ export function reconstructCommand(
return parts.join(" ");
}
/**
* Apply parameter values to a parameterized spell
* Substitutes parameter placeholders with actual values
*
* @param parsed - Parsed spell (must have parameter configuration)
* @param args - Arguments to substitute (if empty, uses defaults)
* @returns Filter with parameters applied
*/
export function applySpellParameters(
parsed: ParsedSpell,
args: string[] = [],
): NostrFilter {
if (!parsed.parameter) {
// Not a parameterized spell, return filter as-is
return parsed.filter;
}
// Use provided args or fall back to defaults
const values = args.length > 0 ? args : parsed.parameter.default || [];
if (values.length === 0) {
throw new Error(
`Parameterized spell requires ${parsed.parameter.type} argument(s)`,
);
}
// Clone the filter
const filter: NostrFilter = { ...parsed.filter };
// Apply substitution based on parameter type
switch (parsed.parameter.type) {
case "$pubkey":
// Substitute in authors array
if (filter.authors) {
filter.authors = filter.authors.flatMap((author) =>
author === "$pubkey" ? values : [author],
);
}
// Substitute in #p tag filters
if (filter["#p"]) {
filter["#p"] = filter["#p"].flatMap((p) =>
p === "$pubkey" ? values : [p],
);
}
// Substitute in #P tag filters
if (filter["#P"]) {
filter["#P"] = filter["#P"].flatMap((p) =>
p === "$pubkey" ? values : [p],
);
}
break;
case "$event":
// Substitute in #e tag filters
if (filter["#e"]) {
filter["#e"] = filter["#e"].flatMap((e) =>
e === "$event" ? values : [e],
);
}
// Substitute in #a tag filters
if (filter["#a"]) {
filter["#a"] = filter["#a"].flatMap((a) =>
a === "$event" ? values : [a],
);
}
// Substitute in ids array
if (filter.ids) {
filter.ids = filter.ids.flatMap((id) =>
id === "$event" ? values : [id],
);
}
break;
case "$relay":
// Relay parameters are handled differently
// They could affect relay hints or #r tag filters
if (filter["#r"]) {
filter["#r"] = filter["#r"].flatMap((r) =>
r === "$relay" ? values : [r],
);
}
break;
}
return filter;
}

View File

@@ -72,6 +72,10 @@ export interface LocalSpell {
eventId?: string; // Nostr event ID if published
event?: SpellEvent; // Full signed event for rebroadcasting
deletedAt?: number; // Timestamp when soft-deleted
// Parameter configuration for parameterized spells (lenses)
parameterType?: "$pubkey" | "$event" | "$relay"; // Type of parameter
parameterDefault?: string[]; // Default values (e.g., ["$me"])
}
export interface LocalSpellbook {

View File

@@ -81,10 +81,24 @@ export interface ParsedSpell {
/** Fork provenance (event ID of source spell) */
forkedFrom?: string;
/** Parameter configuration (if spell is parameterized) */
parameter?: SpellParameter;
/** Full event for reference */
event: SpellEvent;
}
/**
* Parameter configuration for a parameterized spell
*/
export interface SpellParameter {
/** Parameter type */
type: "$pubkey" | "$event" | "$relay";
/** Default values to use when parameter not provided (e.g., ["$me"]) */
default?: string[];
}
/**
* Options for creating a spell from a REQ command
*/
@@ -103,6 +117,9 @@ export interface CreateSpellOptions {
/** If forking, provide source event ID */
forkedFrom?: string;
/** Parameter configuration to make spell reusable */
parameter?: SpellParameter;
}
/**