@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.
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
195
|
-
if (summaries.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
|
|
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}`;
|