@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.
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
183
|
-
|
|
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
|
|
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
|
-
}
|