@forloop-cc/forloop-opencode-plugin-planner 2.2.18 → 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.18",
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",
@@ -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 {
@@ -213,37 +223,12 @@ export function createEventHook(client: ForLoopAPIClient) {
213
223
  preview: bufferedText.substring(0, 500),
214
224
  });
215
225
 
216
- const pendingUserMsg = userMessageBuffer.get(info.sessionID);
217
226
  userMessageBuffer.delete(info.sessionID);
218
227
 
219
- if (!pendingUserMsg) {
220
- console.log('[ForLoop] Skipping write — no buffered user message for this session (stream handler handles the save)');
221
- break;
222
- }
223
-
224
- const conversationId = buildConversationId(
225
- sprintId,
226
- info.agent || 'unknown',
227
- info.sessionID
228
- );
229
- writeConversationTurn(client, {
230
- operation: 'append_turn',
231
- sprintId,
232
- targetAgent: info.agent ? normalizeAgentKey(info.agent) : 'unknown',
233
- actor: getActor(),
234
- conversationId,
235
- sessionId: info.sessionID,
236
- messageId: info.id,
237
- userMessage: pendingUserMsg.content,
238
- agentResponse: bufferedText,
239
- metadata: {
240
- source: 'opencode-plugin',
241
- agent: info.agent ? normalizeAgentKey(info.agent) : null,
242
- model: info.model || null,
243
- userMessageId: pendingUserMsg.messageId || null,
244
- recordedAt: info.time?.created ?? Date.now(),
245
- },
246
- }, 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');
247
232
  break;
248
233
  }
249
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({
@@ -191,28 +192,18 @@ export function createConversationHistoryTool(client: ForLoopAPIClient) {
191
192
  });
192
193
 
193
194
  const summaries = history?.summaries || [];
194
- const messages = history?.messages || [];
195
- if (summaries.length === 0 && messages.length === 0) {
195
+ const turns = history?.turns || [];
196
+ if (summaries.length === 0 && turns.length === 0) {
196
197
  return 'No conversation history found.';
197
198
  }
198
199
 
200
+ const section = renderPromptSection({ summaries, turns });
199
201
  const lines = [
200
- `💬 Conversation History (${history.total || messages.length} total)`,
202
+ `💬 Conversation History (${history.total ?? turns.length} total, ${turns.length} turns)`,
201
203
  '',
204
+ section,
202
205
  ];
203
206
 
204
- for (const summary of summaries) {
205
- lines.push('📋 **Sprint summary**', '', summary, '');
206
- }
207
-
208
- for (const msg of messages.slice(0, args.limit)) {
209
- const badge = msg.role === 'assistant' ? '🤖 Assistant' : '👤 User';
210
- const content = String(msg.content || '');
211
- const preview = content.substring(0, 120);
212
- lines.push(`${badge}: ${preview}${content.length > 120 ? '...' : ''}`);
213
- lines.push('');
214
- }
215
-
216
207
  return lines.join('\n');
217
208
  } catch (error: any) {
218
209
  return `❌ Error: ${error.message}`;