@forloop-cc/forloop-opencode-plugin-planner 2.2.17 → 2.2.19

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,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@forloop-cc/forloop-opencode-plugin-planner",
4
- "version": "2.2.17",
4
+ "version": "2.2.19",
5
5
  "description": "ForLoop integration plugin for opencode — Sprint, story, and agent tools for the ForLoop platform",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -119,7 +119,6 @@ export class ForLoopAPIClient {
119
119
 
120
120
  private async ensureOpencodeApiToken(endpoint: string): Promise<void> {
121
121
  if (this.tokenType !== 'jwt') return;
122
- if (!endpoint.startsWith('/api/opencode/')) return;
123
122
  if (endpoint.startsWith('/api/opencode/auth/tokens')) return;
124
123
 
125
124
  if (!this.jwtExchangePromise) {
@@ -221,7 +220,20 @@ export class ForLoopAPIClient {
221
220
  throw error;
222
221
  }
223
222
 
224
- return response.json();
223
+ if (response.status === 204) {
224
+ return undefined as unknown as T;
225
+ }
226
+
227
+ const text = await response.text();
228
+ if (!text) {
229
+ return undefined as unknown as T;
230
+ }
231
+
232
+ try {
233
+ return JSON.parse(text) as T;
234
+ } catch {
235
+ throw new APIError('invalid_response', 'Failed to parse response body as JSON', 502);
236
+ }
225
237
  } catch (error) {
226
238
  lastError = error as Error;
227
239
 
@@ -456,8 +468,9 @@ export class ForLoopAPIClient {
456
468
  async getConversationHistory(params?: ConversationHistoryParams): Promise<any> {
457
469
  const query = new URLSearchParams();
458
470
  if (params?.sprintId) query.append('sprintId', String(params.sprintId));
471
+ if (params?.conversationId) query.append('conversationId', String(params.conversationId));
459
472
  if (params?.limit) query.append('limit', String(params.limit));
460
- return this.request(`/api/opencode/messages?${query}`);
473
+ return this.request(`/api/opencode/conversations/context?${query}`);
461
474
  }
462
475
 
463
476
  async clearConversationHistory(params?: ClearHistoryParams): Promise<any> {
@@ -623,6 +636,7 @@ export interface WriteConversationEventInput {
623
636
 
624
637
  export interface ConversationHistoryParams {
625
638
  sprintId?: number;
639
+ conversationId?: string;
626
640
  limit?: number;
627
641
  }
628
642
 
@@ -0,0 +1,135 @@
1
+ // GENERATED from server_lambda_shared/src/services/conversationId.ts — do not edit. Run: node scripts/sync_conversation_id.mjs
2
+ // Single source of truth for conversation/context ids. Dependency-free.
3
+ export const PLANNER_AGENT_KEY = 'forLoopPlanner'
4
+ export const A2A_PEER_AGENT_KEY = 'a2a-peer'
5
+
6
+ const SENDER_TYPES = new Set(['user', 'agent', 'system', 'appuser'])
7
+
8
+ export type SenderType = 'user' | 'agent' | 'system' | 'appuser'
9
+ export type ConversationIdKind = 'agent' | 'appuser' | 'meeting' | 'summary' | 'unknown'
10
+
11
+ export interface ParsedConversationId {
12
+ kind: ConversationIdKind
13
+ raw: string
14
+ sprintId: number | null
15
+ agentKey?: string
16
+ senderType?: string
17
+ senderId?: string
18
+ sessionId?: string | null
19
+ userId?: string
20
+ meetingId?: string
21
+ threadKey?: string
22
+ range?: string
23
+ }
24
+
25
+ export interface BuildAgentThreadInput {
26
+ sprintId: number
27
+ agentKey: string
28
+ senderType: SenderType
29
+ senderId: string | number
30
+ sessionId?: string | null
31
+ }
32
+
33
+ export function buildAgentThreadId(input: BuildAgentThreadInput): string {
34
+ const base = `sprint:${input.sprintId}:agent:${input.agentKey}:${input.senderType}:${input.senderId}`
35
+ const session = input.sessionId != null && String(input.sessionId).trim() ? String(input.sessionId).trim() : null
36
+ return session ? `${base}:session:${session}` : base
37
+ }
38
+
39
+ export function buildAppUserThreadId(sprintId: number, agentPublicId: string, userId: number | string): string {
40
+ return `sprint:${sprintId}:agent:${agentPublicId}:appuser:${userId}`
41
+ }
42
+
43
+ export function buildMeetingThreadId(sprintId: number, meetingId: string, userId: number | string): string {
44
+ return `sprint:${sprintId}:meeting:${meetingId}:summary:user:${userId}`
45
+ }
46
+
47
+ export function buildSummaryId(input: { sprintId: number; agentKey: string; threadKey?: string; rangeStart: number; rangeEnd: number }): string {
48
+ const prefix = `sprint:${input.sprintId}:agent:${input.agentKey}:summary:`
49
+ return input.threadKey
50
+ ? `${prefix}${input.threadKey}:${input.rangeStart}-${input.rangeEnd}`
51
+ : `${prefix}${input.rangeStart}-${input.rangeEnd}`
52
+ }
53
+
54
+ export function webAppThreadId(sprintId: number, userId: number | string): string {
55
+ return buildAgentThreadId({ sprintId, agentKey: PLANNER_AGENT_KEY, senderType: 'user', senderId: userId })
56
+ }
57
+
58
+ const isNum = (v: string | undefined): number | null => {
59
+ if (v === undefined || !/^-?\d+$/.test(v)) return null
60
+ return Number(v)
61
+ }
62
+
63
+ export function parseConversationId(id: string): ParsedConversationId {
64
+ const raw = String(id ?? '')
65
+ const parts = raw.split(':')
66
+ const unknown: ParsedConversationId = { kind: 'unknown', raw, sprintId: null }
67
+ if (parts[0] !== 'sprint' || parts.length < 3) return unknown
68
+ const sprintId = isNum(parts[1])
69
+ if (sprintId === null) return unknown
70
+
71
+ if (parts[2] === 'appuser') {
72
+ const userId = parts.slice(3).join(':')
73
+ if (!userId) return unknown
74
+ return { kind: 'appuser', raw, sprintId, userId }
75
+ }
76
+ if (parts[2] === 'meeting' && parts.length >= 4 && parts[3]) {
77
+ const userId = parts[4] === 'summary' && parts[5] === 'user' ? parts.slice(6).join(':') : undefined
78
+ return { kind: 'meeting', raw, sprintId, meetingId: parts[3], ...(userId ? { userId } : {}) }
79
+ }
80
+ if (parts[2] === 'agent' && parts.length >= 6) {
81
+ const agentKey = parts[3]
82
+ if (!agentKey) return unknown
83
+ const senderType = parts[4]
84
+ const sessionIdx = parts.indexOf('session', 5)
85
+ const hasSession = sessionIdx >= 5
86
+ const senderId = (hasSession ? parts.slice(5, sessionIdx) : parts.slice(5)).join(':')
87
+ const sessionId = hasSession ? parts.slice(sessionIdx + 1).join(':') || null : null
88
+ if (senderType === 'summary') {
89
+ const rest = parts.slice(5).join(':')
90
+ // Legacy: `sprint:{id}:agent:{agent}:summary:{start}-{end}` (no threadKey).
91
+ if (/^\d+-\d+$/.test(rest)) {
92
+ return { kind: 'summary', raw, sprintId, agentKey, range: rest }
93
+ }
94
+ // Thread-scoped: `...:summary:{threadKey}:{start}-{end}` where threadKey contains colons.
95
+ const m = rest.match(/^(.*):(\d+-\d+)$/)
96
+ if (m) {
97
+ return { kind: 'summary', raw, sprintId, agentKey, threadKey: m[1], range: m[2] }
98
+ }
99
+ return { kind: 'summary', raw, sprintId, agentKey, ...(rest ? { threadKey: rest } : {}) }
100
+ }
101
+ if (!senderId || !SENDER_TYPES.has(senderType)) return unknown
102
+ return { kind: 'agent', raw, sprintId, agentKey, senderType, senderId, sessionId }
103
+ }
104
+ return unknown
105
+ }
106
+
107
+ export function isCanonicalConversationId(id: string): boolean {
108
+ return parseConversationId(id).kind !== 'unknown'
109
+ }
110
+
111
+ export function isAppUserThread(id: string): boolean {
112
+ const p = parseConversationId(id)
113
+ return p.kind === 'appuser' || (p.kind === 'agent' && p.senderType === 'appuser')
114
+ }
115
+
116
+ export function validateConversationId(id: string): { ok: true } | { ok: false; error: string } {
117
+ return isCanonicalConversationId(id) ? { ok: true } : { ok: false, error: 'Invalid conversationId format' }
118
+ }
119
+
120
+ // One thread per producer: the producer's canonical base id with any `:session:`
121
+ // suffix stripped, so each sender (web human, AI user, chrome extension, peer,
122
+ // appuser) owns its own channel and only its own sessions collapse into it.
123
+ // Producers are never folded into one another.
124
+ export function conversationGroupKey(id: string): string | null {
125
+ const p = parseConversationId(id)
126
+ if (p.kind === 'unknown' || p.sprintId === null) return null
127
+ if (p.kind === 'agent') {
128
+ if (!p.agentKey) return null
129
+ return `sprint:${p.sprintId}:agent:${p.agentKey}:${p.senderType}:${p.senderId}`
130
+ }
131
+ if (p.kind === 'appuser') return id
132
+ if (p.kind === 'meeting') return id
133
+ // summary rows are attributed separately, not listed as channels
134
+ return null
135
+ }
@@ -0,0 +1,85 @@
1
+ // GENERATED from server_lambda_shared/src/services/conversationInjection.ts — do not edit. Run: node scripts/sync_conversation_id.mjs
2
+ export interface InjectionBudget {
3
+ maxTurns?: number
4
+ maxSummaryChars?: number
5
+ maxCharsPerTurn?: number
6
+ }
7
+
8
+ export interface InjectedSummary {
9
+ text: string
10
+ roundsCovered: string | null
11
+ timestamp?: Date | string | null
12
+ owner?: string
13
+ }
14
+
15
+ export interface InjectedTurn {
16
+ id?: number
17
+ conversationId?: string
18
+ timestamp?: Date | string | null
19
+ userMessage: string
20
+ agentResponse: string
21
+ metadata?: unknown
22
+ }
23
+
24
+ export interface InjectionSource {
25
+ summaries: InjectedSummary[]
26
+ turns: InjectedTurn[]
27
+ }
28
+
29
+ export interface InjectedContext {
30
+ summaries: InjectedSummary[]
31
+ turns: InjectedTurn[]
32
+ }
33
+
34
+ function trunc(s: string, n: number): string {
35
+ return Number.isFinite(n) && n > 0 && s.length > n ? s.slice(0, n) + '... [truncated]' : s
36
+ }
37
+
38
+ export function buildInjectedContext(view: InjectionSource, budget: InjectionBudget = {}): InjectedContext {
39
+ const coveredEnd = view.summaries.reduce((max, s) => {
40
+ const end = Number(String(s.roundsCovered || '').split('-')[1])
41
+ return Number.isFinite(end) && end > max ? end : max
42
+ }, 0)
43
+ const maxTurns = budget.maxTurns ?? Infinity
44
+ const maxPer = budget.maxCharsPerTurn ?? Infinity
45
+ const maxSummary = budget.maxSummaryChars ?? Infinity
46
+ const turns = view.turns
47
+ .filter((_, i) => i + 1 > coveredEnd) // positional: drop turns already covered by a summary
48
+ .slice(-maxTurns)
49
+ .map((t) => ({ ...t, userMessage: trunc(t.userMessage, maxPer), agentResponse: trunc(t.agentResponse, maxPer) }))
50
+ // The summary block is capped in aggregate: join all summaries into one text
51
+ // and truncate once, so the injected context holds a single capped summary.
52
+ const combined = view.summaries.map((s) => s.text).filter(Boolean).join('\n\n')
53
+ const capped = trunc(combined, maxSummary)
54
+ const anchor = view.summaries.reduce((best, s) => {
55
+ const e = Number(String(s.roundsCovered || '').split('-')[1])
56
+ const b = Number(String(best?.roundsCovered || '').split('-')[1])
57
+ return Number.isFinite(e) && (!best || !Number.isFinite(b) || e > b) ? s : best
58
+ }, undefined as InjectedSummary | undefined)
59
+ const summaries = capped && anchor ? [{ ...anchor, text: capped }] : []
60
+ return { summaries, turns }
61
+ }
62
+
63
+ export function renderChatMessages(ctx: InjectedContext): { role: 'system' | 'user' | 'assistant'; content: string }[] {
64
+ const out: { role: 'system' | 'user' | 'assistant'; content: string }[] = []
65
+ for (const s of ctx.summaries) if (s.text) out.push({ role: 'system', content: s.text })
66
+ for (const t of ctx.turns) {
67
+ if (t.userMessage) out.push({ role: 'user', content: t.userMessage })
68
+ if (t.agentResponse) out.push({ role: 'assistant', content: t.agentResponse })
69
+ }
70
+ return out
71
+ }
72
+
73
+ export function renderPromptSection(ctx: InjectedContext): string {
74
+ const parts: string[] = []
75
+ const summaryText = ctx.summaries.map((s) => s.text).filter(Boolean).join('\n\n')
76
+ if (summaryText) { parts.push('Conversation summary:'); parts.push(summaryText) }
77
+ if (ctx.turns.length) {
78
+ parts.push('Conversation history:')
79
+ for (const t of ctx.turns) {
80
+ if (t.userMessage) parts.push(`user: ${t.userMessage}`)
81
+ if (t.agentResponse) parts.push(`assistant: ${t.agentResponse}`)
82
+ }
83
+ }
84
+ return parts.join('\n')
85
+ }
@@ -1,5 +1,6 @@
1
1
  import { ForLoopAPIClient } from '../capabilities/api-client';
2
2
  import type { WriteConversationEventInput } from '../capabilities/api-client';
3
+ import { buildAgentThreadId } from '../capabilities/conversationId';
3
4
  import { isLambdaExecution } from '../capabilities/config';
4
5
  import fs from 'fs';
5
6
  import path from 'path';
@@ -25,7 +26,16 @@ function getActor(): { senderType: string; senderId: string } {
25
26
 
26
27
  function buildConversationId(sprintId: number, agent: string, sessionId: string): string {
27
28
  const actor = getActor();
28
- return `sprint:${sprintId}:agent:${agent}:${actor.senderType}:${actor.senderId}`;
29
+ // NOTE: sessionId is intentionally NOT part of the thread id — it is stored in
30
+ // metadata so all of a conversation's turns share one thread (memory preload
31
+ // keys on this base id). `sessionId` is passed to the write call separately.
32
+ return buildAgentThreadId({
33
+ sprintId,
34
+ agentKey: agent,
35
+ senderType: (actor.senderType as 'user' | 'agent' | 'system') || 'user',
36
+ senderId: actor.senderId || 'unknown',
37
+ sessionId: null,
38
+ });
29
39
  }
30
40
 
31
41
  function readActiveSprintId(): number | null {
@@ -75,6 +85,22 @@ function extractTextFromParts(parts: any[]): string {
75
85
  .join('');
76
86
  }
77
87
 
88
+ function isPendingUserMessagePart(messageID: string): boolean {
89
+ if (!messageID) return false;
90
+ for (const pending of userMessageBuffer.values()) {
91
+ if (pending.messageId === messageID) return true;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ function looksLikeInjectedContext(text: string): boolean {
97
+ return (
98
+ text.includes('Current user request:') ||
99
+ text.includes('Conversation history:') ||
100
+ text.includes('Conversation summary:')
101
+ );
102
+ }
103
+
78
104
  export function createChatMessageHook(client: ForLoopAPIClient) {
79
105
  return async (input: any, output: any) => {
80
106
  const sprintId = readActiveSprintId();
@@ -110,6 +136,7 @@ export function createEventHook(client: ForLoopAPIClient) {
110
136
  const textBuffer = new Map<string, string>();
111
137
  const readyToStream = new Set<string>(); // messageIDs that have passed the context phase
112
138
  const directSentText = new Map<string, string>(); // trackingId → last full text sent via direct HTTP
139
+ const directSentIndex = new Map<string, number>(); // trackingId → next chunk index
113
140
 
114
141
  const sendStreamDelta = (sprintId: number, trackingId: string, taskId: string, fullText: string) => {
115
142
  const lastSent = directSentText.get(trackingId) || '';
@@ -117,12 +144,14 @@ export function createEventHook(client: ForLoopAPIClient) {
117
144
  const delta = fullText.startsWith(lastSent) ? fullText.substring(lastSent.length) : fullText;
118
145
  if (!delta) return;
119
146
  directSentText.set(trackingId, fullText);
147
+ const index = directSentIndex.get(trackingId) || 0;
148
+ directSentIndex.set(trackingId, index + 1);
120
149
  client.sendStreamChunk({
121
150
  taskId: taskId || '',
122
151
  trackingId,
123
152
  sprintId,
124
153
  chunk: delta,
125
- index: 0,
154
+ index,
126
155
  }).catch((err: Error) => {
127
156
  console.warn('[ForLoop] Direct stream chunk failed:', err.message);
128
157
  });
@@ -153,12 +182,19 @@ export function createEventHook(client: ForLoopAPIClient) {
153
182
  break;
154
183
  }
155
184
 
156
- // Skip the initial context text (the conversation history loaded by the agent).
157
- // Context text is massive (30K+ chars), assistant responses are small (<5K).
158
- // Also track that we've seen the context and subsequent text is streamable.
159
- const textLen = (part.text || '').length;
160
- if (!readyToStream.has(messageID) && textLen > 5000) {
161
- if (debugStream) console.error('[stream-event] skipping context text', { trackingId, messageID, textLen });
185
+ // Text parts that belong to the pending user message carry the
186
+ // injected conversation history — never stream them as agent output.
187
+ if (isPendingUserMessagePart(messageID || '')) {
188
+ if (debugStream) console.error('[stream-event] skipping user-message text', { trackingId, messageID });
189
+ break;
190
+ }
191
+
192
+ // Fallback: the very first text part of a message that carries the
193
+ // injected-prompt markers is a replay of the loaded context, not a
194
+ // genuine response. A length threshold alone would discard long
195
+ // assistant replies.
196
+ if (!readyToStream.has(messageID) && looksLikeInjectedContext(part.text || '')) {
197
+ if (debugStream) console.error('[stream-event] skipping injected context text', { trackingId, messageID });
162
198
  readyToStream.add(messageID); // mark as past context
163
199
  break;
164
200
  }
@@ -166,6 +202,7 @@ export function createEventHook(client: ForLoopAPIClient) {
166
202
  readyToStream.add(messageID);
167
203
  const current = textBuffer.get(part.messageID) || '';
168
204
  textBuffer.set(part.messageID, part.text || current);
205
+ const textLen = (part.text || '').length;
169
206
  if (debugStream) console.error('[stream-event] writing chunk', { trackingId, textLen });
170
207
 
171
208
  // Send delta directly for real-time streaming (primary path)
@@ -186,37 +223,12 @@ export function createEventHook(client: ForLoopAPIClient) {
186
223
  preview: bufferedText.substring(0, 500),
187
224
  });
188
225
 
189
- const pendingUserMsg = userMessageBuffer.get(info.sessionID);
190
226
  userMessageBuffer.delete(info.sessionID);
191
227
 
192
- if (!pendingUserMsg) {
193
- console.log('[ForLoop] Skipping write — no buffered user message for this session (stream handler handles the save)');
194
- break;
195
- }
196
-
197
- const conversationId = buildConversationId(
198
- sprintId,
199
- info.agent || 'unknown',
200
- info.sessionID
201
- );
202
- writeConversationTurn(client, {
203
- operation: 'append_turn',
204
- sprintId,
205
- targetAgent: info.agent ? normalizeAgentKey(info.agent) : 'unknown',
206
- actor: getActor(),
207
- conversationId,
208
- sessionId: info.sessionID,
209
- messageId: info.id,
210
- userMessage: pendingUserMsg.content,
211
- agentResponse: bufferedText,
212
- metadata: {
213
- source: 'opencode-plugin',
214
- agent: info.agent ? normalizeAgentKey(info.agent) : null,
215
- model: info.model || null,
216
- userMessageId: pendingUserMsg.messageId || null,
217
- recordedAt: info.time?.created ?? Date.now(),
218
- },
219
- }, 3);
228
+ // In the lambda streaming path the stream handler owns persistence
229
+ // (it has the authoritative conversationId + actor). Do not write here
230
+ // too, or every turn is recorded twice.
231
+ console.log('[ForLoop] Skipping plugin write — stream handler handles the save');
220
232
  break;
221
233
  }
222
234
  case 'message.removed': {
@@ -2,6 +2,7 @@ import { tool } from '@opencode-ai/plugin';
2
2
  import { ForLoopAPIClient } from '../capabilities/api-client';
3
3
  import { resolveSprintId } from '../capabilities/context-resolver';
4
4
  import { validateToken } from '../capabilities/auth';
5
+ import { renderPromptSection } from '../capabilities/conversationInjection';
5
6
 
6
7
  export function createAgentSuggestTool(client: ForLoopAPIClient) {
7
8
  return tool({
@@ -173,47 +174,36 @@ export function createConversationHistoryTool(client: ForLoopAPIClient) {
173
174
  const resolution = await resolveSprintId(args.sprintId, context.directory);
174
175
  const sprintId = resolution.sprintId || args.sprintId;
175
176
 
177
+ if (typeof sprintId !== 'number' || !Number.isInteger(sprintId) || sprintId <= 0) {
178
+ return [
179
+ '❌ No sprint ID provided.',
180
+ '',
181
+ 'Please specify a sprint ID using one of these methods:',
182
+ ' 1. Pass --sprintId flag',
183
+ ' 2. Set FORLOOP_SPRINT_ID environment variable',
184
+ ' 3. Use a git branch named sprint-XXX (e.g., sprint-123)',
185
+ ].join('\n');
186
+ }
187
+
176
188
  try {
177
189
  const history = await client.getConversationHistory({
178
190
  sprintId,
179
191
  limit: args.limit,
180
192
  });
181
193
 
182
- const messages = history.messages || [];
183
- if (messages.length === 0) {
194
+ const summaries = history?.summaries || [];
195
+ const turns = history?.turns || [];
196
+ if (summaries.length === 0 && turns.length === 0) {
184
197
  return 'No conversation history found.';
185
198
  }
186
199
 
200
+ const section = renderPromptSection({ summaries, turns });
187
201
  const lines = [
188
- `💬 Conversation History (${history.total || messages.length} total)`,
202
+ `💬 Conversation History (${history.total ?? turns.length} total, ${turns.length} turns)`,
189
203
  '',
204
+ section,
190
205
  ];
191
206
 
192
- for (const msg of messages.slice(0, args.limit)) {
193
- const preview = (msg.userMessage || 'No message').substring(0, 80);
194
- const hasResponse = msg.agentResponse && msg.agentResponse.trim();
195
-
196
- lines.push(`📌 **${preview}**${preview.length >= 80 ? '...' : ''}`);
197
-
198
- if (msg.conversationId) {
199
- const agentMatch = msg.conversationId.match(/agent:([^:]+)/);
200
- if (agentMatch) {
201
- lines.push(` Agent: ${agentMatch[1]} | ${formatDate(msg.timestamp)}`);
202
- } else {
203
- lines.push(` ${formatDate(msg.timestamp)}`);
204
- }
205
- } else {
206
- lines.push(` ${formatDate(msg.timestamp)}`);
207
- }
208
-
209
- if (hasResponse) {
210
- const respPreview = msg.agentResponse.substring(0, 100);
211
- lines.push(` → ${respPreview}${msg.agentResponse.length > 100 ? '...' : ''}`);
212
- }
213
-
214
- lines.push('');
215
- }
216
-
217
207
  return lines.join('\n');
218
208
  } catch (error: any) {
219
209
  return `❌ Error: ${error.message}`;
@@ -264,12 +254,3 @@ export function createClearHistoryTool(client: ForLoopAPIClient) {
264
254
  },
265
255
  });
266
256
  }
267
-
268
- function formatDate(dateString: string): string {
269
- return new Date(dateString).toLocaleDateString('en-US', {
270
- month: 'short',
271
- day: 'numeric',
272
- hour: '2-digit',
273
- minute: '2-digit',
274
- });
275
- }