twindex-openclaw-plugin 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/openclaw.plugin.json +34 -0
- package/package.json +31 -0
- package/skills/twindex/SKILL.md +61 -0
- package/src/client.ts +159 -0
- package/src/index.ts +485 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "twindex",
|
|
3
|
+
"name": "Twindex",
|
|
4
|
+
"description": "Music intelligence for AI agents. Get notified about tours, merch drops, releases, and presales for your favorite artists.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"skills": ["./skills"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"apiKey": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"description": "Twindex API key (starts with twx_). Obtained during setup."
|
|
14
|
+
},
|
|
15
|
+
"frequency": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": ["realtime", "periodic", "daily"],
|
|
18
|
+
"default": "periodic",
|
|
19
|
+
"description": "How often to check for notifications. realtime = every 5 min, periodic = every hour, daily = once per day."
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"uiHints": {
|
|
24
|
+
"apiKey": {
|
|
25
|
+
"label": "Twindex API Key",
|
|
26
|
+
"placeholder": "twx_...",
|
|
27
|
+
"sensitive": true
|
|
28
|
+
},
|
|
29
|
+
"frequency": {
|
|
30
|
+
"label": "Check frequency",
|
|
31
|
+
"placeholder": "periodic"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "twindex-openclaw-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Music intelligence for AI agents. Tours, merch drops, releases, presales.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"extensions": ["./src/index.ts"]
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"openclaw": "*"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"openclaw",
|
|
15
|
+
"openclaw-plugin",
|
|
16
|
+
"music",
|
|
17
|
+
"notifications",
|
|
18
|
+
"artists",
|
|
19
|
+
"tours",
|
|
20
|
+
"merch"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/naveenspark/twindex"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"openclaw.plugin.json",
|
|
28
|
+
"src/",
|
|
29
|
+
"skills/"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Twindex
|
|
3
|
+
description: Music intelligence — tours, merch drops, releases, presales for your favorite artists.
|
|
4
|
+
tools:
|
|
5
|
+
- twindex_setup
|
|
6
|
+
- twindex_subscribe
|
|
7
|
+
- twindex_unsubscribe
|
|
8
|
+
- twindex_check
|
|
9
|
+
- twindex_subscriptions
|
|
10
|
+
- twindex_artist
|
|
11
|
+
- twindex_search
|
|
12
|
+
- twindex_artists
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Twindex — Music Intelligence
|
|
16
|
+
|
|
17
|
+
You have the Twindex plugin installed. It tracks artists and delivers
|
|
18
|
+
notifications about tours, merch drops, releases, and presales.
|
|
19
|
+
|
|
20
|
+
## When to use
|
|
21
|
+
|
|
22
|
+
- User mentions a band, artist, or musician they follow
|
|
23
|
+
- User asks about concerts, tours, merch, or new releases
|
|
24
|
+
- User wants to stay updated on music news
|
|
25
|
+
|
|
26
|
+
## Setup flow
|
|
27
|
+
|
|
28
|
+
1. Use `twindex_artists` or `twindex_search` to find artists in the index.
|
|
29
|
+
2. Ask your user: **"Which artists do you want to follow, and how quickly
|
|
30
|
+
do you want updates — real-time, every hour, or a daily summary?"**
|
|
31
|
+
3. Wait for their answer. Do not assume a preference.
|
|
32
|
+
4. Call `twindex_setup` with their chosen artists and frequency.
|
|
33
|
+
- "real-time" / "ASAP" / "immediately" → `frequency: "realtime"`
|
|
34
|
+
- "every hour" / "periodically" / "every few hours" → `frequency: "periodic"`
|
|
35
|
+
- "daily" / "once a day" / "summary" → `frequency: "daily"`
|
|
36
|
+
5. Confirm what you set up: "Subscribed to [artists]. Checking [frequency]."
|
|
37
|
+
|
|
38
|
+
## Adding more artists later
|
|
39
|
+
|
|
40
|
+
Use `twindex_subscribe` to add artists. The existing delivery schedule
|
|
41
|
+
picks them up automatically — do NOT run twindex_setup again.
|
|
42
|
+
|
|
43
|
+
## Checking manually
|
|
44
|
+
|
|
45
|
+
Use `twindex_check` to look for updates right now.
|
|
46
|
+
|
|
47
|
+
## Viewing artist details
|
|
48
|
+
|
|
49
|
+
Use `twindex_artist` with an artist slug to get their full page (tours,
|
|
50
|
+
merch, releases, bio).
|
|
51
|
+
|
|
52
|
+
## Viewing current subscriptions
|
|
53
|
+
|
|
54
|
+
Use `twindex_subscriptions` to see what the user is currently following.
|
|
55
|
+
|
|
56
|
+
## Important
|
|
57
|
+
|
|
58
|
+
- Do NOT create cron jobs, scheduled tasks, or shell scripts.
|
|
59
|
+
- Do NOT use curl commands to call Twindex APIs.
|
|
60
|
+
- All notification delivery is handled by the plugin automatically.
|
|
61
|
+
- Only speak when you have something to deliver. Never say "no updates."
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Twindex API client — all URLs hardcoded. Zero hallucination.
|
|
2
|
+
// Base: https://twindex.ai (NOT api.twindex.ai)
|
|
3
|
+
|
|
4
|
+
const BASE_URL = "https://twindex.ai";
|
|
5
|
+
|
|
6
|
+
interface RegisterResponse {
|
|
7
|
+
agent: { id: string; name: string };
|
|
8
|
+
api_key: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Subscription {
|
|
12
|
+
id: string;
|
|
13
|
+
brand: string;
|
|
14
|
+
event_types: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Notification {
|
|
18
|
+
id: string;
|
|
19
|
+
brand: string;
|
|
20
|
+
event_type: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
twindex_url?: string;
|
|
23
|
+
detail_url?: string;
|
|
24
|
+
created_at: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Artist {
|
|
28
|
+
slug: string;
|
|
29
|
+
name: string;
|
|
30
|
+
genres?: string[];
|
|
31
|
+
status?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SearchResult {
|
|
35
|
+
slug: string;
|
|
36
|
+
name: string;
|
|
37
|
+
score: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function request<T>(
|
|
41
|
+
path: string,
|
|
42
|
+
options: RequestInit = {},
|
|
43
|
+
): Promise<T> {
|
|
44
|
+
const url = `${BASE_URL}${path}`;
|
|
45
|
+
const res = await fetch(url, {
|
|
46
|
+
...options,
|
|
47
|
+
signal: AbortSignal.timeout(10_000),
|
|
48
|
+
headers: {
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
...options.headers,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const body = await res.text().catch(() => "");
|
|
56
|
+
throw new Error(`Twindex API ${res.status}: ${body}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return res.json() as Promise<T>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function authHeader(apiKey: string): Record<string, string> {
|
|
63
|
+
return { Authorization: `Bearer ${apiKey}` };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Register a new agent. Returns api_key (shown only once). */
|
|
67
|
+
export async function register(
|
|
68
|
+
agentName: string,
|
|
69
|
+
): Promise<RegisterResponse> {
|
|
70
|
+
return request("/api/v1/agents/register", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
body: JSON.stringify({ name: agentName }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Subscribe to an artist. */
|
|
77
|
+
export async function subscribe(
|
|
78
|
+
apiKey: string,
|
|
79
|
+
brand: string,
|
|
80
|
+
eventTypes: string[] = [
|
|
81
|
+
"tour",
|
|
82
|
+
"merch_drop",
|
|
83
|
+
"release",
|
|
84
|
+
"presale",
|
|
85
|
+
"update",
|
|
86
|
+
],
|
|
87
|
+
): Promise<Subscription> {
|
|
88
|
+
return request("/api/v1/subscriptions", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: authHeader(apiKey),
|
|
91
|
+
body: JSON.stringify({ brand, event_types: eventTypes }),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** List current subscriptions. */
|
|
96
|
+
export async function listSubscriptions(
|
|
97
|
+
apiKey: string,
|
|
98
|
+
): Promise<Subscription[]> {
|
|
99
|
+
return request("/api/v1/subscriptions", {
|
|
100
|
+
headers: authHeader(apiKey),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Unsubscribe from an artist. */
|
|
105
|
+
export async function unsubscribe(
|
|
106
|
+
apiKey: string,
|
|
107
|
+
brand: string,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
await request(`/api/v1/subscriptions/${brand}`, {
|
|
110
|
+
method: "DELETE",
|
|
111
|
+
headers: authHeader(apiKey),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Get unread notifications (all subscriptions). */
|
|
116
|
+
export async function getNotifications(
|
|
117
|
+
apiKey: string,
|
|
118
|
+
): Promise<Notification[]> {
|
|
119
|
+
return request("/api/v1/notifications", {
|
|
120
|
+
headers: authHeader(apiKey),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Mark notifications as read. */
|
|
125
|
+
export async function markRead(
|
|
126
|
+
apiKey: string,
|
|
127
|
+
ids: string[],
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
await request("/api/v1/notifications/read", {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: authHeader(apiKey),
|
|
132
|
+
body: JSON.stringify({ ids }),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Search the music index (no auth). */
|
|
137
|
+
export async function search(query: string): Promise<SearchResult[]> {
|
|
138
|
+
return request(
|
|
139
|
+
`/api/v1/search?q=${encodeURIComponent(query)}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** List all indexed artists (no auth). */
|
|
144
|
+
export async function listArtists(): Promise<Artist[]> {
|
|
145
|
+
return request("/api/v1/artists");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Get a single artist page as markdown (no auth). */
|
|
149
|
+
export async function getArtist(slug: string): Promise<string> {
|
|
150
|
+
const url = `${BASE_URL}/${slug}`;
|
|
151
|
+
const res = await fetch(url, {
|
|
152
|
+
signal: AbortSignal.timeout(10_000),
|
|
153
|
+
headers: { Accept: "text/markdown" },
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
throw new Error(`Twindex API ${res.status}`);
|
|
157
|
+
}
|
|
158
|
+
return res.text();
|
|
159
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import * as twindex from "./client.js";
|
|
2
|
+
|
|
3
|
+
let pendingDelivery: string[] = [];
|
|
4
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
5
|
+
|
|
6
|
+
export default function register(api: any) {
|
|
7
|
+
const cfg = () => api.config?.plugins?.entries?.twindex?.config ?? {};
|
|
8
|
+
|
|
9
|
+
function persistConfig(updates: Record<string, any>) {
|
|
10
|
+
if (!api.config?.plugins?.entries?.twindex) return;
|
|
11
|
+
api.config.plugins.entries.twindex.config = {
|
|
12
|
+
...cfg(),
|
|
13
|
+
...updates,
|
|
14
|
+
};
|
|
15
|
+
// OpenClaw auto-persists config mutations on the entries object.
|
|
16
|
+
// If a future version requires explicit save, call api.config.save?.() here.
|
|
17
|
+
api.config.save?.();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Tools ──────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
api.registerTool({
|
|
23
|
+
name: "twindex_setup",
|
|
24
|
+
description:
|
|
25
|
+
"Set up Twindex music notifications. Registers with Twindex, subscribes to artists, and configures delivery. Call this after asking your user which artists they want and how often they want updates.",
|
|
26
|
+
parameters: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
artists: {
|
|
30
|
+
type: "array",
|
|
31
|
+
items: { type: "string" },
|
|
32
|
+
description:
|
|
33
|
+
"Artist slugs to subscribe to (lowercase, hyphens). Examples: metallica, the-cure, slipknot, korn.",
|
|
34
|
+
},
|
|
35
|
+
frequency: {
|
|
36
|
+
type: "string",
|
|
37
|
+
enum: ["realtime", "periodic", "daily"],
|
|
38
|
+
description:
|
|
39
|
+
"How often to deliver updates. realtime = every 5 min, periodic = every hour, daily = once per day.",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: ["artists", "frequency"],
|
|
43
|
+
},
|
|
44
|
+
async execute(_id: string, params: { artists: string[]; frequency: string }) {
|
|
45
|
+
try {
|
|
46
|
+
const config = cfg();
|
|
47
|
+
let apiKey = config.apiKey;
|
|
48
|
+
|
|
49
|
+
if (!apiKey) {
|
|
50
|
+
const agentId = api.agentId ?? api.config?.agentId ?? `openclaw-${crypto.randomUUID()}`;
|
|
51
|
+
const reg = await twindex.register(agentId);
|
|
52
|
+
apiKey = reg.api_key;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
persistConfig({
|
|
56
|
+
apiKey,
|
|
57
|
+
frequency: params.frequency,
|
|
58
|
+
intervalMinutes: frequencyToMinutes(params.frequency),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const results: string[] = [];
|
|
62
|
+
for (const artist of params.artists) {
|
|
63
|
+
try {
|
|
64
|
+
await twindex.subscribe(apiKey, artist);
|
|
65
|
+
results.push(`Subscribed to ${artist}`);
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
results.push(`${artist}: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
startPolling(api, apiKey, params.frequency);
|
|
72
|
+
|
|
73
|
+
const intervalDesc = frequencyDescription(params.frequency);
|
|
74
|
+
results.push(`Delivery configured: ${intervalDesc}`);
|
|
75
|
+
results.push(
|
|
76
|
+
"Notifications will be delivered automatically. No cron jobs needed.",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: results.join("\n") }],
|
|
81
|
+
};
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: `Setup failed: ${err.message}` }],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
api.registerTool({
|
|
91
|
+
name: "twindex_subscribe",
|
|
92
|
+
description:
|
|
93
|
+
"Subscribe to a new artist on Twindex. The existing delivery job will automatically pick up notifications for this artist.",
|
|
94
|
+
parameters: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
artist: {
|
|
98
|
+
type: "string",
|
|
99
|
+
description: "Artist slug (lowercase, hyphens). Example: the-cure",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
required: ["artist"],
|
|
103
|
+
},
|
|
104
|
+
async execute(_id: string, params: { artist: string }) {
|
|
105
|
+
const apiKey = cfg().apiKey;
|
|
106
|
+
if (!apiKey) {
|
|
107
|
+
return {
|
|
108
|
+
content: [
|
|
109
|
+
{ type: "text", text: "Not set up yet. Use twindex_setup first." },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await twindex.subscribe(apiKey, params.artist);
|
|
116
|
+
return {
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
type: "text",
|
|
120
|
+
text: `Subscribed to ${params.artist}. Your existing delivery schedule will include this artist automatically.`,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
} catch (err: any) {
|
|
125
|
+
return {
|
|
126
|
+
content: [
|
|
127
|
+
{ type: "text", text: `Failed to subscribe to ${params.artist}: ${err.message}` },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
api.registerTool({
|
|
135
|
+
name: "twindex_unsubscribe",
|
|
136
|
+
description: "Unsubscribe from an artist on Twindex.",
|
|
137
|
+
parameters: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
artist: {
|
|
141
|
+
type: "string",
|
|
142
|
+
description: "Artist slug to unsubscribe from.",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
required: ["artist"],
|
|
146
|
+
},
|
|
147
|
+
async execute(_id: string, params: { artist: string }) {
|
|
148
|
+
const apiKey = cfg().apiKey;
|
|
149
|
+
if (!apiKey) {
|
|
150
|
+
return {
|
|
151
|
+
content: [
|
|
152
|
+
{ type: "text", text: "Not set up yet. Use twindex_setup first." },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await twindex.unsubscribe(apiKey, params.artist);
|
|
159
|
+
return {
|
|
160
|
+
content: [
|
|
161
|
+
{ type: "text", text: `Unsubscribed from ${params.artist}.` },
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
} catch (err: any) {
|
|
165
|
+
return {
|
|
166
|
+
content: [
|
|
167
|
+
{ type: "text", text: `Failed to unsubscribe from ${params.artist}: ${err.message}` },
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
api.registerTool({
|
|
175
|
+
name: "twindex_check",
|
|
176
|
+
description:
|
|
177
|
+
"Check for unread Twindex notifications right now. Returns any pending artist updates.",
|
|
178
|
+
parameters: {
|
|
179
|
+
type: "object",
|
|
180
|
+
properties: {},
|
|
181
|
+
},
|
|
182
|
+
async execute() {
|
|
183
|
+
// Drain the in-process buffer first (avoids double-delivery with poll service)
|
|
184
|
+
if (pendingDelivery.length > 0) {
|
|
185
|
+
const buffered = pendingDelivery.splice(0);
|
|
186
|
+
return {
|
|
187
|
+
content: [
|
|
188
|
+
{
|
|
189
|
+
type: "text",
|
|
190
|
+
text: `${buffered.length} update(s):\n${buffered.join("\n")}`,
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const apiKey = cfg().apiKey;
|
|
197
|
+
if (!apiKey) {
|
|
198
|
+
return {
|
|
199
|
+
content: [
|
|
200
|
+
{ type: "text", text: "Not set up yet. Use twindex_setup first." },
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const notifications = await twindex.getNotifications(apiKey);
|
|
207
|
+
if (notifications.length === 0) {
|
|
208
|
+
return {
|
|
209
|
+
content: [
|
|
210
|
+
{ type: "text", text: "No unread notifications." },
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const lines = formatNotifications(notifications);
|
|
216
|
+
|
|
217
|
+
const ids = notifications.map((n) => n.id);
|
|
218
|
+
await twindex.markRead(apiKey, ids);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
content: [
|
|
222
|
+
{
|
|
223
|
+
type: "text",
|
|
224
|
+
text: `${notifications.length} update(s):\n${lines.join("\n")}`,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
} catch (err: any) {
|
|
229
|
+
return {
|
|
230
|
+
content: [
|
|
231
|
+
{ type: "text", text: `Failed to check notifications: ${err.message}` },
|
|
232
|
+
],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
api.registerTool({
|
|
239
|
+
name: "twindex_subscriptions",
|
|
240
|
+
description: "List your current Twindex subscriptions.",
|
|
241
|
+
parameters: {
|
|
242
|
+
type: "object",
|
|
243
|
+
properties: {},
|
|
244
|
+
},
|
|
245
|
+
async execute() {
|
|
246
|
+
const apiKey = cfg().apiKey;
|
|
247
|
+
if (!apiKey) {
|
|
248
|
+
return {
|
|
249
|
+
content: [
|
|
250
|
+
{ type: "text", text: "Not set up yet. Use twindex_setup first." },
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const subs = await twindex.listSubscriptions(apiKey);
|
|
257
|
+
if (!subs || subs.length === 0) {
|
|
258
|
+
return {
|
|
259
|
+
content: [{ type: "text", text: "No active subscriptions." }],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const lines = subs.map(
|
|
264
|
+
(s) => `${s.brand} (${s.event_types.join(", ")})`,
|
|
265
|
+
);
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
268
|
+
};
|
|
269
|
+
} catch (err: any) {
|
|
270
|
+
return {
|
|
271
|
+
content: [
|
|
272
|
+
{ type: "text", text: `Failed to list subscriptions: ${err.message}` },
|
|
273
|
+
],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
api.registerTool({
|
|
280
|
+
name: "twindex_artist",
|
|
281
|
+
description:
|
|
282
|
+
"Get the full Twindex page for an artist. Returns detailed info including tours, merch, releases.",
|
|
283
|
+
parameters: {
|
|
284
|
+
type: "object",
|
|
285
|
+
properties: {
|
|
286
|
+
artist: {
|
|
287
|
+
type: "string",
|
|
288
|
+
description: "Artist slug (lowercase, hyphens). Example: slipknot",
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
required: ["artist"],
|
|
292
|
+
},
|
|
293
|
+
async execute(_id: string, params: { artist: string }) {
|
|
294
|
+
try {
|
|
295
|
+
const page = await twindex.getArtist(params.artist);
|
|
296
|
+
return {
|
|
297
|
+
content: [{ type: "text", text: page }],
|
|
298
|
+
};
|
|
299
|
+
} catch (err: any) {
|
|
300
|
+
return {
|
|
301
|
+
content: [
|
|
302
|
+
{ type: "text", text: `Failed to get artist page: ${err.message}` },
|
|
303
|
+
],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
api.registerTool({
|
|
310
|
+
name: "twindex_search",
|
|
311
|
+
description:
|
|
312
|
+
"Search the Twindex music index. Find artists by name, genre, or keyword.",
|
|
313
|
+
parameters: {
|
|
314
|
+
type: "object",
|
|
315
|
+
properties: {
|
|
316
|
+
query: {
|
|
317
|
+
type: "string",
|
|
318
|
+
description: "Search query. Examples: 'metal bands', 'the cure', 'punk rock'.",
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
required: ["query"],
|
|
322
|
+
},
|
|
323
|
+
async execute(_id: string, params: { query: string }) {
|
|
324
|
+
try {
|
|
325
|
+
const results = await twindex.search(params.query);
|
|
326
|
+
if (!results || results.length === 0) {
|
|
327
|
+
return {
|
|
328
|
+
content: [{ type: "text", text: "No artists found." }],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const lines = results.map(
|
|
333
|
+
(r) => `${r.name} (${r.slug}) — relevance: ${r.score.toFixed(2)}`,
|
|
334
|
+
);
|
|
335
|
+
return {
|
|
336
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
337
|
+
};
|
|
338
|
+
} catch (err: any) {
|
|
339
|
+
return {
|
|
340
|
+
content: [
|
|
341
|
+
{ type: "text", text: `Search failed: ${err.message}` },
|
|
342
|
+
],
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
api.registerTool({
|
|
349
|
+
name: "twindex_artists",
|
|
350
|
+
description: "List all artists currently indexed on Twindex.",
|
|
351
|
+
parameters: {
|
|
352
|
+
type: "object",
|
|
353
|
+
properties: {},
|
|
354
|
+
},
|
|
355
|
+
async execute() {
|
|
356
|
+
try {
|
|
357
|
+
const artists = await twindex.listArtists();
|
|
358
|
+
if (!artists || artists.length === 0) {
|
|
359
|
+
return {
|
|
360
|
+
content: [{ type: "text", text: "No artists indexed yet." }],
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const lines = artists.map(
|
|
365
|
+
(a) =>
|
|
366
|
+
`${a.name} (${a.slug})${a.genres?.length ? ` — ${a.genres.join(", ")}` : ""}`,
|
|
367
|
+
);
|
|
368
|
+
return {
|
|
369
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
370
|
+
};
|
|
371
|
+
} catch (err: any) {
|
|
372
|
+
return {
|
|
373
|
+
content: [
|
|
374
|
+
{ type: "text", text: `Failed to list artists: ${err.message}` },
|
|
375
|
+
],
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ── Lifecycle Hook: inject notifications into agent context ────────
|
|
382
|
+
|
|
383
|
+
api.on("before_agent_start", async () => {
|
|
384
|
+
if (pendingDelivery.length === 0) return;
|
|
385
|
+
|
|
386
|
+
const messages = pendingDelivery.splice(0);
|
|
387
|
+
return {
|
|
388
|
+
systemMessage: [
|
|
389
|
+
"TWINDEX NOTIFICATIONS (deliver these to your user):",
|
|
390
|
+
...messages,
|
|
391
|
+
].join("\n"),
|
|
392
|
+
};
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ── Background Service: poll for notifications ─────────────────────
|
|
396
|
+
|
|
397
|
+
api.registerService?.({
|
|
398
|
+
id: "twindex-poll",
|
|
399
|
+
start: () => {
|
|
400
|
+
const config = cfg();
|
|
401
|
+
if (config.apiKey && config.frequency) {
|
|
402
|
+
startPolling(api, config.apiKey, config.frequency);
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
stop: () => {
|
|
406
|
+
if (pollTimer) {
|
|
407
|
+
clearInterval(pollTimer);
|
|
408
|
+
pollTimer = null;
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
function frequencyToMinutes(frequency: string): number {
|
|
417
|
+
switch (frequency) {
|
|
418
|
+
case "realtime":
|
|
419
|
+
return 5;
|
|
420
|
+
case "daily":
|
|
421
|
+
return 1440;
|
|
422
|
+
case "periodic":
|
|
423
|
+
default:
|
|
424
|
+
return 60;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function frequencyDescription(frequency: string): string {
|
|
429
|
+
switch (frequency) {
|
|
430
|
+
case "realtime":
|
|
431
|
+
return "checking every 5 minutes";
|
|
432
|
+
case "daily":
|
|
433
|
+
return "checking once per day";
|
|
434
|
+
case "periodic":
|
|
435
|
+
default:
|
|
436
|
+
return "checking every hour";
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function formatNotifications(
|
|
441
|
+
notifications: Array<{
|
|
442
|
+
brand: string;
|
|
443
|
+
event_type: string;
|
|
444
|
+
summary: string;
|
|
445
|
+
detail_url?: string;
|
|
446
|
+
}>,
|
|
447
|
+
): string[] {
|
|
448
|
+
return notifications.map(
|
|
449
|
+
(n) =>
|
|
450
|
+
`[${n.brand}] ${n.event_type}: ${n.summary}${n.detail_url ? ` — ${n.detail_url}` : ""}`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function startPolling(api: any, apiKey: string, frequency: string) {
|
|
455
|
+
if (pollTimer) {
|
|
456
|
+
clearInterval(pollTimer);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const intervalMs = frequencyToMinutes(frequency) * 60 * 1000;
|
|
460
|
+
|
|
461
|
+
pollTimer = setInterval(async () => {
|
|
462
|
+
try {
|
|
463
|
+
const notifications = await twindex.getNotifications(apiKey);
|
|
464
|
+
if (notifications.length === 0) return;
|
|
465
|
+
|
|
466
|
+
// Queue for delivery BEFORE marking read — if process crashes after
|
|
467
|
+
// markRead, notifications are lost. This way they survive in the buffer.
|
|
468
|
+
const lines = formatNotifications(notifications);
|
|
469
|
+
pendingDelivery.push(...lines);
|
|
470
|
+
|
|
471
|
+
const ids = notifications.map((n) => n.id);
|
|
472
|
+
await twindex.markRead(apiKey, ids);
|
|
473
|
+
|
|
474
|
+
api.logger?.info?.(
|
|
475
|
+
`Twindex: ${notifications.length} notification(s) queued for delivery`,
|
|
476
|
+
);
|
|
477
|
+
} catch (err: any) {
|
|
478
|
+
api.logger?.warn?.(`Twindex poll error: ${err.message}`);
|
|
479
|
+
}
|
|
480
|
+
}, intervalMs);
|
|
481
|
+
|
|
482
|
+
api.logger?.info?.(
|
|
483
|
+
`Twindex: polling every ${frequencyToMinutes(frequency)} min`,
|
|
484
|
+
);
|
|
485
|
+
}
|