agentfeed 0.1.6 → 0.1.8

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/scanner.js CHANGED
@@ -1,50 +1,104 @@
1
- export async function scanUnprocessed(client, agent) {
1
+ import { parseMention, isBotAuthor } from "./utils.js";
2
+ export async function scanUnprocessed(client, agent, followStore, postSessionStore) {
2
3
  const triggers = [];
3
4
  const feeds = await client.getFeeds();
5
+ const processedPostIds = new Set();
4
6
  for (const feed of feeds) {
7
+ // --- Scan comments (existing logic) ---
5
8
  const comments = await client.getFeedComments(feed.id, {
6
9
  author_type: "human",
7
10
  limit: 50,
8
11
  });
12
+ // Group comments by post to find the last human comment per post
13
+ const byPost = new Map();
9
14
  for (const comment of comments.data) {
10
- let shouldTrigger = false;
11
- let triggerType = "own_post_comment";
12
- // Check if this is on an agent-owned post
13
- if (comment.post_created_by === agent.id) {
14
- shouldTrigger = true;
15
- triggerType = "own_post_comment";
15
+ const list = byPost.get(comment.post_id) ?? [];
16
+ list.push(comment);
17
+ byPost.set(comment.post_id, list);
18
+ }
19
+ for (const [postId, postComments] of byPost) {
20
+ // Determine the best trigger type for this post
21
+ let bestTriggerType = null;
22
+ let bestComment = null;
23
+ let bestSessionName = "default";
24
+ for (const comment of postComments) {
25
+ // Check for @mentions (highest priority)
26
+ const mention = parseMention(comment.content, agent.name);
27
+ if (mention.mentioned) {
28
+ bestTriggerType = "mention";
29
+ bestComment = comment;
30
+ bestSessionName = mention.sessionName;
31
+ }
32
+ // Check if this is on an agent-owned post
33
+ if (!bestTriggerType && comment.post_created_by === agent.id) {
34
+ bestTriggerType = "own_post_comment";
35
+ bestComment = comment;
36
+ bestSessionName = postSessionStore?.get(postId) ?? "default";
37
+ }
16
38
  }
17
- // Check for @mentions
18
- if (containsMention(comment.content, agent.name)) {
19
- shouldTrigger = true;
20
- triggerType = "mention";
39
+ // Check if this is in a followed thread
40
+ if (!bestTriggerType && followStore?.has(postId)) {
41
+ bestTriggerType = "thread_follow_up";
42
+ // Use the last human comment as the trigger
43
+ bestComment = postComments[postComments.length - 1] ?? null;
44
+ bestSessionName = postSessionStore?.get(postId) ?? "default";
21
45
  }
22
- if (!shouldTrigger)
46
+ if (!bestTriggerType || !bestComment)
23
47
  continue;
24
- // Check if there's already a bot reply after this comment
25
- const replies = await client.getPostComments(comment.post_id, {
26
- since: comment.created_at,
48
+ // Check if there's a bot reply after the LAST human comment in this post
49
+ const lastHumanComment = postComments[postComments.length - 1];
50
+ const replies = await client.getPostComments(postId, {
51
+ since: lastHumanComment.created_at,
27
52
  author_type: "bot",
28
53
  limit: 1,
29
54
  });
30
55
  if (replies.data.length === 0) {
31
56
  triggers.push({
32
- triggerType,
33
- eventId: comment.id,
57
+ triggerType: bestTriggerType,
58
+ eventId: bestComment.id,
34
59
  feedId: feed.id,
35
60
  feedName: feed.name,
36
- postId: comment.post_id,
37
- postTitle: comment.post_title,
38
- content: comment.content,
39
- authorName: comment.author_name,
61
+ postId,
62
+ content: bestComment.content,
63
+ authorName: bestComment.author_name,
64
+ sessionName: bestSessionName,
65
+ });
66
+ processedPostIds.add(postId);
67
+ }
68
+ }
69
+ // --- Scan post bodies for @mentions ---
70
+ const posts = await client.getFeedPosts(feed.id, { limit: 50 });
71
+ for (const post of posts.data) {
72
+ // Skip posts already handled by comment scan
73
+ if (processedPostIds.has(post.id))
74
+ continue;
75
+ // Skip bot-authored posts
76
+ if (isBotAuthor(post.created_by))
77
+ continue;
78
+ // Skip posts without content
79
+ if (!post.content)
80
+ continue;
81
+ const mention = parseMention(post.content, agent.name);
82
+ if (!mention.mentioned)
83
+ continue;
84
+ // Check if there's any bot reply on this post
85
+ const botReplies = await client.getPostComments(post.id, {
86
+ author_type: "bot",
87
+ limit: 1,
88
+ });
89
+ if (botReplies.data.length === 0) {
90
+ triggers.push({
91
+ triggerType: "mention",
92
+ eventId: post.id,
93
+ feedId: feed.id,
94
+ feedName: feed.name,
95
+ postId: post.id,
96
+ content: post.content,
97
+ authorName: post.author_name,
98
+ sessionName: mention.sessionName,
40
99
  });
41
100
  }
42
101
  }
43
102
  }
44
103
  return triggers;
45
104
  }
46
- function containsMention(text, agentName) {
47
- const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
- const regex = new RegExp(`@${escaped}\\b`, "i");
49
- return regex.test(text);
50
- }
@@ -1,10 +1,10 @@
1
- export declare class SessionStore {
1
+ import { PersistentStore } from "./persistent-store.js";
2
+ export declare class SessionStore extends PersistentStore {
2
3
  private map;
3
- private filePath;
4
4
  constructor(filePath?: string);
5
+ protected serialize(): string;
6
+ protected deserialize(raw: string): void;
5
7
  get(postId: string): string | undefined;
6
8
  set(postId: string, sessionId: string): void;
7
9
  delete(postId: string): void;
8
- private load;
9
- private save;
10
10
  }
@@ -1,14 +1,19 @@
1
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { homedir } from "node:os";
4
- const DEFAULT_DIR = join(homedir(), ".agentfeed");
5
- export class SessionStore {
1
+ import { PersistentStore } from "./persistent-store.js";
2
+ export class SessionStore extends PersistentStore {
6
3
  map = new Map();
7
- filePath;
8
4
  constructor(filePath) {
9
- this.filePath = filePath ?? join(DEFAULT_DIR, "sessions.json");
5
+ super("sessions.json", filePath);
10
6
  this.load();
11
7
  }
8
+ serialize() {
9
+ return JSON.stringify(Object.fromEntries(this.map), null, 2);
10
+ }
11
+ deserialize(raw) {
12
+ const data = JSON.parse(raw);
13
+ for (const [k, v] of Object.entries(data)) {
14
+ this.map.set(k, v);
15
+ }
16
+ }
12
17
  get(postId) {
13
18
  return this.map.get(postId);
14
19
  }
@@ -20,26 +25,4 @@ export class SessionStore {
20
25
  this.map.delete(postId);
21
26
  this.save();
22
27
  }
23
- load() {
24
- try {
25
- const raw = readFileSync(this.filePath, "utf-8");
26
- const data = JSON.parse(raw);
27
- for (const [k, v] of Object.entries(data)) {
28
- this.map.set(k, v);
29
- }
30
- }
31
- catch {
32
- // File doesn't exist or invalid JSON — start fresh
33
- }
34
- }
35
- save() {
36
- try {
37
- mkdirSync(dirname(this.filePath), { recursive: true });
38
- const data = Object.fromEntries(this.map);
39
- writeFileSync(this.filePath, JSON.stringify(data, null, 2), "utf-8");
40
- }
41
- catch (err) {
42
- console.error("Failed to save sessions:", err);
43
- }
44
- }
45
28
  }
@@ -6,4 +6,4 @@ export interface SSEEvent {
6
6
  export interface SSEConnection {
7
7
  close: () => void;
8
8
  }
9
- export declare function connectSSE(url: string, apiKey: string, onEvent: (event: SSEEvent) => void, onError: (error: Error) => void): SSEConnection;
9
+ export declare function connectSSE(url: string, apiKey: string, agentId: string | undefined, onEvent: (event: SSEEvent) => void, onError: (error: Error) => void): SSEConnection;
@@ -1,31 +1,94 @@
1
1
  import { EventSource } from "eventsource";
2
- const EVENT_TYPES = ["heartbeat", "post_created", "comment_created"];
3
- export function connectSSE(url, apiKey, onEvent, onError) {
4
- const es = new EventSource(url, {
5
- fetch: (input, init) => fetch(input, {
6
- ...init,
7
- headers: { ...init.headers, Authorization: `Bearer ${apiKey}` },
8
- }),
9
- });
10
- es.onopen = () => {
11
- console.log("SSE connected.");
12
- };
13
- es.onerror = (err) => {
14
- if (es.readyState === EventSource.CLOSED) {
15
- onError(new Error(err.message ?? "SSE connection closed"));
16
- }
17
- // CONNECTING state = auto-reconnecting, no action needed
18
- };
19
- for (const eventType of EVENT_TYPES) {
20
- es.addEventListener(eventType, (e) => {
21
- onEvent({
22
- type: eventType,
23
- data: e.data,
24
- id: e.lastEventId || undefined,
25
- });
2
+ const EVENT_TYPES = ["heartbeat", "post_created", "comment_created", "session_deleted"];
3
+ const BACKOFF_INITIAL_MS = 1_000;
4
+ const BACKOFF_MAX_MS = 60_000;
5
+ const BACKOFF_RESET_AFTER_MS = 30_000;
6
+ export function connectSSE(url, apiKey, agentId, onEvent, onError) {
7
+ let closed = false;
8
+ let currentEs = null;
9
+ let backoffMs = BACKOFF_INITIAL_MS;
10
+ let reconnectTimer = null;
11
+ let lastConnectedAt = 0;
12
+ let processedEventIds = new Set();
13
+ let eventIdCleanupTimer = null;
14
+ function connect() {
15
+ if (closed)
16
+ return;
17
+ const es = new EventSource(url, {
18
+ fetch: (input, init) => fetch(input, {
19
+ ...init,
20
+ headers: {
21
+ ...init.headers,
22
+ Authorization: `Bearer ${apiKey}`,
23
+ ...(agentId ? { "X-Agent-Id": agentId } : {}),
24
+ },
25
+ }),
26
26
  });
27
+ currentEs = es;
28
+ es.onopen = () => {
29
+ const now = Date.now();
30
+ const isFirstConnect = lastConnectedAt === 0;
31
+ // Only reset backoff if previous connection was stable (lasted > 30s)
32
+ if (!isFirstConnect && now - lastConnectedAt > BACKOFF_RESET_AFTER_MS) {
33
+ backoffMs = BACKOFF_INITIAL_MS;
34
+ }
35
+ if (isFirstConnect) {
36
+ console.log("SSE connected.");
37
+ }
38
+ else {
39
+ console.log("SSE reconnected.");
40
+ }
41
+ lastConnectedAt = now;
42
+ };
43
+ es.onerror = (err) => {
44
+ // EventSource auto-reconnects on its own, but we want manual control
45
+ // Close the current one and reconnect with backoff
46
+ es.close();
47
+ currentEs = null;
48
+ if (closed)
49
+ return;
50
+ if (es.readyState === EventSource.CLOSED) {
51
+ onError(new Error(err.message ?? "SSE connection closed"));
52
+ }
53
+ console.log(`SSE disconnected. Reconnecting in ${backoffMs / 1000}s...`);
54
+ reconnectTimer = setTimeout(() => {
55
+ reconnectTimer = null;
56
+ connect();
57
+ }, backoffMs);
58
+ // Exponential backoff: 1s -> 2s -> 4s -> 8s -> ... -> 60s
59
+ backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS);
60
+ };
61
+ for (const eventType of EVENT_TYPES) {
62
+ es.addEventListener(eventType, (e) => {
63
+ // Deduplicate events by ID
64
+ const eventId = e.lastEventId || undefined;
65
+ if (eventId) {
66
+ if (processedEventIds.has(eventId))
67
+ return;
68
+ processedEventIds.add(eventId);
69
+ }
70
+ onEvent({
71
+ type: eventType,
72
+ data: e.data,
73
+ id: eventId,
74
+ });
75
+ });
76
+ }
27
77
  }
78
+ // Periodically clean old event IDs to prevent memory growth
79
+ eventIdCleanupTimer = setInterval(() => {
80
+ processedEventIds = new Set();
81
+ }, 5 * 60 * 1000);
82
+ connect();
28
83
  return {
29
- close: () => es.close(),
84
+ close: () => {
85
+ closed = true;
86
+ if (reconnectTimer)
87
+ clearTimeout(reconnectTimer);
88
+ if (eventIdCleanupTimer)
89
+ clearInterval(eventIdCleanupTimer);
90
+ currentEs?.close();
91
+ currentEs = null;
92
+ },
30
93
  };
31
94
  }
package/dist/trigger.d.ts CHANGED
@@ -1,2 +1,4 @@
1
+ import type { FollowStore } from "./follow-store.js";
2
+ import type { PostSessionStore } from "./post-session-store.js";
1
3
  import type { GlobalEvent, TriggerContext, AgentInfo } from "./types.js";
2
- export declare function detectTrigger(event: GlobalEvent, agent: AgentInfo): TriggerContext | null;
4
+ export declare function detectTrigger(event: GlobalEvent, agent: AgentInfo, followStore?: FollowStore, postSessionStore?: PostSessionStore): TriggerContext | null;
package/dist/trigger.js CHANGED
@@ -1,4 +1,5 @@
1
- export function detectTrigger(event, agent) {
1
+ import { parseMention } from "./utils.js";
2
+ export function detectTrigger(event, agent, followStore, postSessionStore) {
2
3
  if (event.type === "comment_created") {
3
4
  // Trigger 1: Comment on own post
4
5
  if (event.post_created_by === agent.id) {
@@ -8,45 +9,56 @@ export function detectTrigger(event, agent) {
8
9
  feedId: event.feed_id,
9
10
  feedName: "",
10
11
  postId: event.post_id,
11
- postTitle: event.post_title,
12
12
  content: event.content,
13
13
  authorName: event.author_name,
14
+ sessionName: postSessionStore?.get(event.post_id) ?? "default",
14
15
  };
15
16
  }
16
17
  // Trigger 2: @mention in comment
17
- if (containsMention(event.content, agent.name)) {
18
+ const mention = parseMention(event.content, agent.name);
19
+ if (mention.mentioned) {
18
20
  return {
19
21
  triggerType: "mention",
20
22
  eventId: event.id,
21
23
  feedId: event.feed_id,
22
24
  feedName: "",
23
25
  postId: event.post_id,
24
- postTitle: event.post_title,
25
26
  content: event.content,
26
27
  authorName: event.author_name,
28
+ sessionName: mention.sessionName,
27
29
  };
28
30
  }
29
- }
30
- if (event.type === "post_created") {
31
- // Trigger 2: @mention in post title or content
32
- const text = [event.title, event.content].filter(Boolean).join(" ");
33
- if (containsMention(text, agent.name)) {
31
+ // Trigger 3: Comment in a followed thread
32
+ if (followStore?.has(event.post_id)) {
34
33
  return {
35
- triggerType: "mention",
34
+ triggerType: "thread_follow_up",
36
35
  eventId: event.id,
37
36
  feedId: event.feed_id,
38
- feedName: event.feed_name,
39
- postId: event.id,
40
- postTitle: event.title,
41
- content: event.content ?? "",
37
+ feedName: "",
38
+ postId: event.post_id,
39
+ content: event.content,
42
40
  authorName: event.author_name,
41
+ sessionName: postSessionStore?.get(event.post_id) ?? "default",
43
42
  };
44
43
  }
45
44
  }
45
+ if (event.type === "post_created") {
46
+ // @mention in post content
47
+ if (event.content) {
48
+ const mention = parseMention(event.content, agent.name);
49
+ if (mention.mentioned) {
50
+ return {
51
+ triggerType: "mention",
52
+ eventId: event.id,
53
+ feedId: event.feed_id,
54
+ feedName: event.feed_name,
55
+ postId: event.id,
56
+ content: event.content,
57
+ authorName: event.author_name,
58
+ sessionName: mention.sessionName,
59
+ };
60
+ }
61
+ }
62
+ }
46
63
  return null;
47
64
  }
48
- function containsMention(text, agentName) {
49
- const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
- const regex = new RegExp(`@${escaped}\\b`, "i");
51
- return regex.test(text);
52
- }
package/dist/types.d.ts CHANGED
@@ -8,7 +8,6 @@ export interface GlobalPostEvent {
8
8
  id: string;
9
9
  feed_id: string;
10
10
  feed_name: string;
11
- title: string | null;
12
11
  content: string | null;
13
12
  created_by: string | null;
14
13
  author_name: string | null;
@@ -24,24 +23,38 @@ export interface GlobalCommentEvent {
24
23
  created_by: string | null;
25
24
  author_name: string | null;
26
25
  created_at: string;
27
- post_title: string | null;
28
26
  post_created_by: string | null;
29
27
  }
30
- export type GlobalEvent = GlobalPostEvent | GlobalCommentEvent;
28
+ export interface GlobalSessionDeletedEvent {
29
+ type: "session_deleted";
30
+ agent_id: string;
31
+ agent_name: string;
32
+ session_name: string;
33
+ }
34
+ export type GlobalEvent = GlobalPostEvent | GlobalCommentEvent | GlobalSessionDeletedEvent;
31
35
  export interface TriggerContext {
32
- triggerType: "own_post_comment" | "mention";
36
+ triggerType: "own_post_comment" | "mention" | "thread_follow_up";
33
37
  eventId: string;
34
38
  feedId: string;
35
39
  feedName: string;
36
40
  postId: string;
37
- postTitle: string | null;
38
41
  content: string;
39
42
  authorName: string | null;
43
+ sessionName: string;
40
44
  }
41
45
  export interface FeedItem {
42
46
  id: string;
43
47
  name: string;
44
48
  }
49
+ export interface PostItem {
50
+ id: string;
51
+ feed_id: string;
52
+ content: string | null;
53
+ created_by: string;
54
+ author_name: string | null;
55
+ created_at: string;
56
+ comment_count: number;
57
+ }
45
58
  export interface FeedCommentItem {
46
59
  id: string;
47
60
  post_id: string;
@@ -50,7 +63,6 @@ export interface FeedCommentItem {
50
63
  created_by: string | null;
51
64
  author_name: string | null;
52
65
  created_at: string;
53
- post_title: string | null;
54
66
  post_created_by: string | null;
55
67
  }
56
68
  export interface CommentItem {
@@ -67,3 +79,4 @@ export interface PaginatedResponse<T> {
67
79
  next_cursor: string | null;
68
80
  has_more: boolean;
69
81
  }
82
+ export type PermissionMode = "safe" | "yolo";
@@ -0,0 +1,8 @@
1
+ export interface MentionMatch {
2
+ mentioned: boolean;
3
+ sessionName: string;
4
+ }
5
+ export declare function parseMention(text: string, agentName: string): MentionMatch;
6
+ export declare function containsMention(text: string, agentName: string): boolean;
7
+ /** Check if created_by belongs to a bot (API key or registered agent) */
8
+ export declare function isBotAuthor(createdBy: string | null | undefined): boolean;
package/dist/utils.js ADDED
@@ -0,0 +1,15 @@
1
+ export function parseMention(text, agentName) {
2
+ const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3
+ const regex = new RegExp(`@${escaped}(?:/([a-zA-Z0-9_-]+))?\\b`, "i");
4
+ const match = regex.exec(text);
5
+ if (!match)
6
+ return { mentioned: false, sessionName: "default" };
7
+ return { mentioned: true, sessionName: match[1] ?? "default" };
8
+ }
9
+ export function containsMention(text, agentName) {
10
+ return parseMention(text, agentName).mentioned;
11
+ }
12
+ /** Check if created_by belongs to a bot (API key or registered agent) */
13
+ export function isBotAuthor(createdBy) {
14
+ return createdBy?.startsWith("af_") === true || createdBy?.startsWith("ag_") === true;
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentfeed",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Worker daemon for AgentFeed - watches feeds and wakes AI agents via claude -p",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "build": "tsc",
15
- "dev": "tsx src/index.ts"
15
+ "dev": "tsx --env-file=../../.env.local src/index.ts"
16
16
  },
17
17
  "engines": {
18
18
  "node": ">=18"
@@ -23,6 +23,7 @@
23
23
  "typescript": "^5"
24
24
  },
25
25
  "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.26.0",
26
27
  "eventsource": "^4.1.0"
27
28
  }
28
29
  }