opencode-telegram-mirror 0.4.3 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-telegram-mirror",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Standalone bot that mirrors OpenCode sessions to Telegram topics",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
package/src/main.ts CHANGED
@@ -83,6 +83,73 @@ async function updateStatusMessage(
83
83
  log("debug", "Status message update", { messageId, state, success })
84
84
  }
85
85
 
86
+ /**
87
+ * Generate a session title using OpenCode with a lightweight model.
88
+ * Returns { type: "title", value: string } if successful,
89
+ * or { type: "unknown", value: string } if more context is needed.
90
+ */
91
+ type TitleResult =
92
+ | { type: "unknown"; value: string }
93
+ | { type: "title"; value: string }
94
+
95
+ async function generateSessionTitle(
96
+ server: OpenCodeServer,
97
+ userMessage: string
98
+ ): Promise<TitleResult> {
99
+ const tempSession = await server.client.session.create({ title: "title-gen" })
100
+
101
+ if (!tempSession.data) {
102
+ return { type: "unknown", value: "failed to create temp session" }
103
+ }
104
+
105
+ try {
106
+ const response = await server.client.session.prompt({
107
+ sessionID: tempSession.data.id,
108
+ model: { providerID: "opencode", modelID: "glm-4.7-free" },
109
+ system: `You generate short titles for coding sessions based on user messages.
110
+
111
+ If the message provides enough context to understand the task, respond with:
112
+ {"type":"title","value":"<title here>"}
113
+
114
+ If the message is just a branch name, file path, or lacks context to understand what the user wants to do, respond with:
115
+ {"type":"unknown","value":"<brief reason>"}
116
+
117
+ Title rules (when generating):
118
+ - max 50 characters
119
+ - summarize the user's intent
120
+ - one line, no quotes or colons
121
+ - if a Linear ticket ID exists in the message (e.g. APP-550, ENG-123), always prefix the title with it
122
+
123
+ Examples:
124
+ - "feat/add-login" -> {"type":"unknown","value":"branch name only, need task description"}
125
+ - "fix the auth bug in login.ts" -> {"type":"title","value":"Fix auth bug in login"}
126
+ - "src/components/Button.tsx" -> {"type":"unknown","value":"file path only, need task description"}
127
+ - "add dark mode toggle to settings" -> {"type":"title","value":"Add dark mode toggle to settings"}
128
+ - "APP-550: fix auth bug" -> {"type":"title","value":"APP-550: Fix auth bug"}
129
+ - "feat/APP-123-add-user-profile" -> {"type":"unknown","value":"branch name only, need task description"}
130
+ - "working on APP-123 to add user profiles" -> {"type":"title","value":"APP-123: Add user profiles"}
131
+ - "https://linear.app/team/issue/ENG-456/fix-button" -> {"type":"title","value":"ENG-456: Fix button"}
132
+
133
+ Respond with only valid JSON, nothing else.`,
134
+ parts: [{ type: "text", text: userMessage }],
135
+ })
136
+
137
+ const textPart = response.data?.parts?.find(
138
+ (p: { type: string }) => p.type === "text"
139
+ ) as { type: "text"; text: string } | undefined
140
+ const text = textPart?.text?.trim() || ""
141
+
142
+ try {
143
+ return JSON.parse(text) as TitleResult
144
+ } catch {
145
+ // If LLM didn't return valid JSON, treat response as title
146
+ return { type: "title", value: text.slice(0, 50) }
147
+ }
148
+ } finally {
149
+ await server.client.session.delete({ sessionID: tempSession.data.id })
150
+ }
151
+ }
152
+
86
153
  interface BotState {
87
154
  server: OpenCodeServer;
88
155
  telegram: TelegramClient;
@@ -94,6 +161,7 @@ interface BotState {
94
161
  updatesUrl: string | null;
95
162
  botUserId: number | null;
96
163
  sessionId: string | null;
164
+ needsTitle: boolean;
97
165
 
98
166
  assistantMessageIds: Set<string>;
99
167
  pendingParts: Map<string, Part[]>;
@@ -260,6 +328,7 @@ async function main() {
260
328
  updatesUrl: config.updatesUrl || null,
261
329
  botUserId: botInfo.id,
262
330
  sessionId,
331
+ needsTitle: !initialThreadTitle,
263
332
  assistantMessageIds: new Set(),
264
333
  pendingParts: new Map(),
265
334
  sentPartIds: new Set(),
@@ -384,6 +453,7 @@ Do not start implementing until you have clarity on what needs to be done.`
384
453
 
385
454
  if (sessionResult.data?.id) {
386
455
  state.sessionId = sessionResult.data.id
456
+ state.needsTitle = true
387
457
  setSessionId(sessionResult.data.id, log)
388
458
  log("info", "Created OpenCode session", { sessionId: state.sessionId })
389
459
 
@@ -748,6 +818,7 @@ async function handleTelegramMessage(
748
818
  })
749
819
  if (result.data) {
750
820
  state.sessionId = result.data.id
821
+ state.needsTitle = true
751
822
  setSessionId(result.data.id, log)
752
823
  log("info", "Created session for command", { sessionId: result.data.id })
753
824
  } else {
@@ -821,6 +892,7 @@ async function handleTelegramMessage(
821
892
 
822
893
  if (result.data) {
823
894
  state.sessionId = result.data.id
895
+ state.needsTitle = true
824
896
  setSessionId(result.data.id, log)
825
897
  log("info", "Created session", { sessionId: result.data.id })
826
898
  } else {
@@ -926,6 +998,38 @@ async function handleTelegramMessage(
926
998
  })
927
999
 
928
1000
  log("info", "Prompt sent", { sessionId: state.sessionId })
1001
+
1002
+ if (state.needsTitle && state.sessionId) {
1003
+ const textContent = parts
1004
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
1005
+ .map((p) => p.text)
1006
+ .join("\n")
1007
+
1008
+ if (textContent) {
1009
+ generateSessionTitle(state.server, textContent)
1010
+ .then(async (result) => {
1011
+ if (result.type === "title" && state.sessionId) {
1012
+ log("info", "Generated session title", { title: result.value })
1013
+ const updateResult = await state.server.client.session.update({
1014
+ sessionID: state.sessionId,
1015
+ title: result.value,
1016
+ })
1017
+ if (updateResult.data) {
1018
+ state.threadTitle = result.value
1019
+ state.needsTitle = false
1020
+ if (state.threadId) {
1021
+ await state.telegram.editForumTopic(state.threadId, result.value)
1022
+ }
1023
+ }
1024
+ } else {
1025
+ log("debug", "Title generation deferred", { reason: result.value })
1026
+ }
1027
+ })
1028
+ .catch((err) => {
1029
+ log("error", "Title generation failed", { error: String(err) })
1030
+ })
1031
+ }
1032
+ }
929
1033
  }
930
1034
 
931
1035
  async function handleTelegramCallback(
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Quick test for session title generation
4
+ * Usage: bun run test/test-title-gen.ts
5
+ *
6
+ * Requires OPENCODE_URL env var or will start its own server
7
+ */
8
+
9
+ import { startServer, connectToServer, stopServer, type OpenCodeServer } from "../src/opencode"
10
+
11
+ type TitleResult =
12
+ | { type: "unknown"; value: string }
13
+ | { type: "title"; value: string }
14
+
15
+ async function generateSessionTitle(
16
+ server: OpenCodeServer,
17
+ userMessage: string
18
+ ): Promise<TitleResult> {
19
+ const tempSession = await server.client.session.create({ title: "title-gen" })
20
+
21
+ if (!tempSession.data) {
22
+ return { type: "unknown", value: "failed to create temp session" }
23
+ }
24
+
25
+ try {
26
+ const response = await server.client.session.prompt({
27
+ sessionID: tempSession.data.id,
28
+ model: { providerID: "opencode", modelID: "glm-4.7-free" },
29
+ system: `You generate short titles for coding sessions based on user messages.
30
+
31
+ If the message provides enough context to understand the task, respond with:
32
+ {"type":"title","value":"<title here>"}
33
+
34
+ If the message is just a branch name, file path, or lacks context to understand what the user wants to do, respond with:
35
+ {"type":"unknown","value":"<brief reason>"}
36
+
37
+ Title rules (when generating):
38
+ - max 50 characters
39
+ - summarize the user's intent
40
+ - one line, no quotes or colons
41
+ - if a Linear ticket ID exists in the message (e.g. APP-550, ENG-123), always prefix the title with it
42
+
43
+ Examples:
44
+ - "feat/add-login" -> {"type":"unknown","value":"branch name only, need task description"}
45
+ - "fix the auth bug in login.ts" -> {"type":"title","value":"Fix auth bug in login"}
46
+ - "src/components/Button.tsx" -> {"type":"unknown","value":"file path only, need task description"}
47
+ - "add dark mode toggle to settings" -> {"type":"title","value":"Add dark mode toggle to settings"}
48
+ - "APP-550: fix auth bug" -> {"type":"title","value":"APP-550: Fix auth bug"}
49
+ - "feat/APP-123-add-user-profile" -> {"type":"unknown","value":"branch name only, need task description"}
50
+ - "working on APP-123 to add user profiles" -> {"type":"title","value":"APP-123: Add user profiles"}
51
+ - "https://linear.app/team/issue/ENG-456/fix-button" -> {"type":"title","value":"ENG-456: Fix button"}
52
+
53
+ Respond with only valid JSON, nothing else.`,
54
+ parts: [{ type: "text", text: userMessage }],
55
+ })
56
+
57
+ const textPart = response.data?.parts?.find(
58
+ (p: { type: string }) => p.type === "text"
59
+ ) as { type: "text"; text: string } | undefined
60
+ const text = textPart?.text?.trim() || ""
61
+
62
+ try {
63
+ return JSON.parse(text) as TitleResult
64
+ } catch {
65
+ return { type: "title", value: text.slice(0, 50) }
66
+ }
67
+ } finally {
68
+ await server.client.session.delete({ sessionID: tempSession.data.id })
69
+ }
70
+ }
71
+
72
+ const testCases = [
73
+ "feat/add-login",
74
+ "fix the auth bug in login.ts",
75
+ "src/components/Button.tsx",
76
+ "add dark mode toggle to settings",
77
+ "APP-550: fix auth bug",
78
+ "feat/APP-123-add-user-profile",
79
+ "working on APP-123 to add user profiles",
80
+ "https://linear.app/team/issue/ENG-456/fix-button",
81
+ "andrew/test-branch",
82
+ ]
83
+
84
+ async function main() {
85
+ const openCodeUrl = process.env.OPENCODE_URL
86
+ let server: OpenCodeServer
87
+ let startedServer = false
88
+
89
+ if (openCodeUrl) {
90
+ console.log(`Connecting to OpenCode at ${openCodeUrl}...`)
91
+ const result = await connectToServer(openCodeUrl, process.cwd())
92
+ if (result.status === "error") {
93
+ console.error("Failed to connect:", result.error.message)
94
+ process.exit(1)
95
+ }
96
+ server = result.value
97
+ } else {
98
+ console.log("Starting OpenCode server...")
99
+ const result = await startServer(process.cwd())
100
+ if (result.status === "error") {
101
+ console.error("Failed to start:", result.error.message)
102
+ process.exit(1)
103
+ }
104
+ server = result.value
105
+ startedServer = true
106
+ }
107
+
108
+ console.log("\n=== Testing Title Generation ===\n")
109
+
110
+ for (const testCase of testCases) {
111
+ console.log(`Input: "${testCase}"`)
112
+ try {
113
+ const result = await generateSessionTitle(server, testCase)
114
+ console.log(`Result: ${JSON.stringify(result)}`)
115
+ } catch (err) {
116
+ console.log(`Error: ${err}`)
117
+ }
118
+ console.log()
119
+ }
120
+
121
+ if (startedServer) {
122
+ await stopServer()
123
+ }
124
+ }
125
+
126
+ main().catch(console.error)