forge-openclaw-plugin 0.2.20 → 0.2.22

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.
Files changed (33) hide show
  1. package/dist/assets/{board-DGbXWEuu.js → board-_C6oMy5w.js} +2 -2
  2. package/dist/assets/{board-DGbXWEuu.js.map → board-_C6oMy5w.js.map} +1 -1
  3. package/dist/assets/index-Ch_xeZ2u.js +63 -0
  4. package/dist/assets/index-Ch_xeZ2u.js.map +1 -0
  5. package/dist/assets/index-DvVM7K6j.css +1 -0
  6. package/dist/assets/{motion-B5Qoz2Ci.js → motion-D4sZgCHd.js} +2 -2
  7. package/dist/assets/{motion-B5Qoz2Ci.js.map → motion-D4sZgCHd.js.map} +1 -1
  8. package/dist/assets/{table-D_iurDQu.js → table-BWzTaky1.js} +2 -2
  9. package/dist/assets/{table-D_iurDQu.js.map → table-BWzTaky1.js.map} +1 -1
  10. package/dist/assets/{ui-D5QUYUq4.js → ui-BzK4azQb.js} +2 -2
  11. package/dist/assets/{ui-D5QUYUq4.js.map → ui-BzK4azQb.js.map} +1 -1
  12. package/dist/assets/vendor-De38P6YR.js +729 -0
  13. package/dist/assets/vendor-De38P6YR.js.map +1 -0
  14. package/dist/assets/{viz-BD9WSxHz.js → viz-C6hfyqzu.js} +2 -2
  15. package/dist/assets/{viz-BD9WSxHz.js.map → viz-C6hfyqzu.js.map} +1 -1
  16. package/dist/index.html +8 -8
  17. package/dist/server/app.js +328 -19
  18. package/dist/server/health.js +82 -21
  19. package/dist/server/managers/platform/background-job-manager.js +103 -8
  20. package/dist/server/managers/platform/llm-manager.js +91 -5
  21. package/dist/server/managers/platform/openai-responses-provider.js +683 -70
  22. package/dist/server/repositories/diagnostic-logs.js +243 -0
  23. package/dist/server/repositories/wiki-memory.js +619 -66
  24. package/dist/server/types.js +56 -0
  25. package/openclaw.plugin.json +1 -1
  26. package/package.json +1 -1
  27. package/server/migrations/023_diagnostic_logs.sql +28 -0
  28. package/skills/forge-openclaw/SKILL.md +14 -0
  29. package/dist/assets/index-4-1WI9i7.css +0 -1
  30. package/dist/assets/index-BZbHajNK.js +0 -63
  31. package/dist/assets/index-BZbHajNK.js.map +0 -1
  32. package/dist/assets/vendor-KARp8LAR.js +0 -706
  33. package/dist/assets/vendor-KARp8LAR.js.map +0 -1
@@ -1,3 +1,143 @@
1
+ function emitDiagnostic(logger, input) {
2
+ logger?.(input);
3
+ }
4
+ function truncate(value, limit = 1_600) {
5
+ return value.length > limit ? `${value.slice(0, limit)}…` : value;
6
+ }
7
+ const SUPPORTED_INGEST_ENTITY_TYPES = [
8
+ "goal",
9
+ "project",
10
+ "task",
11
+ "habit",
12
+ "strategy",
13
+ "psyche_value",
14
+ "note"
15
+ ];
16
+ const MODEL_CONTEXT_WINDOWS = {
17
+ "gpt-5.4": 1_050_000,
18
+ "gpt-5.4-mini": 400_000,
19
+ "gpt-5.4-nano": 400_000
20
+ };
21
+ const DEFAULT_CONTEXT_WINDOW = 400_000;
22
+ const RESERVED_RESPONSE_TOKENS = 140_000;
23
+ const APPROX_CHARS_PER_TOKEN = 4;
24
+ const REQUEST_TIMEOUT_MS = 90_000;
25
+ const BACKGROUND_POLL_INTERVAL_MS = 2_000;
26
+ function closedObject(properties) {
27
+ return {
28
+ type: "object",
29
+ additionalProperties: false,
30
+ properties,
31
+ required: Object.keys(properties)
32
+ };
33
+ }
34
+ function stringArraySchema() {
35
+ return {
36
+ type: "array",
37
+ items: { type: "string" }
38
+ };
39
+ }
40
+ function integerArraySchema() {
41
+ return {
42
+ type: "array",
43
+ items: { type: "integer" }
44
+ };
45
+ }
46
+ function nullableStringSchema() {
47
+ return {
48
+ type: ["string", "null"]
49
+ };
50
+ }
51
+ function nullableNumberSchema() {
52
+ return {
53
+ type: ["number", "null"]
54
+ };
55
+ }
56
+ function buildWikiIngestSchema() {
57
+ const linkedEntitySchema = closedObject({
58
+ entityType: {
59
+ type: "string",
60
+ enum: ["goal", "project", "task", "habit", "strategy", "psyche_value"]
61
+ },
62
+ entityId: { type: "string" },
63
+ rationale: nullableStringSchema()
64
+ });
65
+ const suggestedFieldsSchema = closedObject({
66
+ goalId: nullableStringSchema(),
67
+ projectId: nullableStringSchema(),
68
+ horizon: nullableStringSchema(),
69
+ status: nullableStringSchema(),
70
+ priority: nullableStringSchema(),
71
+ dueDate: nullableStringSchema(),
72
+ themeColor: nullableStringSchema(),
73
+ polarity: nullableStringSchema(),
74
+ frequency: nullableStringSchema(),
75
+ endStateDescription: nullableStringSchema(),
76
+ valuedDirection: nullableStringSchema(),
77
+ whyItMatters: nullableStringSchema(),
78
+ userId: nullableStringSchema(),
79
+ targetPoints: nullableNumberSchema(),
80
+ estimatedMinutes: nullableNumberSchema(),
81
+ targetCount: nullableNumberSchema(),
82
+ rewardXp: nullableNumberSchema(),
83
+ penaltyXp: nullableNumberSchema(),
84
+ linkedGoalIds: stringArraySchema(),
85
+ linkedProjectIds: stringArraySchema(),
86
+ linkedTaskIds: stringArraySchema(),
87
+ linkedValueIds: stringArraySchema(),
88
+ targetGoalIds: stringArraySchema(),
89
+ targetProjectIds: stringArraySchema(),
90
+ weekDays: integerArraySchema(),
91
+ linkedEntities: {
92
+ type: "array",
93
+ items: linkedEntitySchema
94
+ },
95
+ committedActions: stringArraySchema(),
96
+ notes: stringArraySchema(),
97
+ tags: stringArraySchema()
98
+ });
99
+ return closedObject({
100
+ title: { type: "string" },
101
+ summary: { type: "string" },
102
+ markdown: { type: "string" },
103
+ tags: stringArraySchema(),
104
+ entityProposals: {
105
+ type: "array",
106
+ items: closedObject({
107
+ entityType: {
108
+ type: "string",
109
+ enum: [...SUPPORTED_INGEST_ENTITY_TYPES]
110
+ },
111
+ title: { type: "string" },
112
+ summary: { type: "string" },
113
+ rationale: { type: "string" },
114
+ confidence: { type: "number" },
115
+ suggestedFields: suggestedFieldsSchema
116
+ })
117
+ },
118
+ pageUpdateSuggestions: {
119
+ type: "array",
120
+ items: closedObject({
121
+ targetSlug: { type: "string" },
122
+ rationale: { type: "string" },
123
+ patchSummary: { type: "string" }
124
+ })
125
+ },
126
+ articleCandidates: {
127
+ type: "array",
128
+ items: closedObject({
129
+ title: { type: "string" },
130
+ slug: { type: "string" },
131
+ parentSlug: nullableStringSchema(),
132
+ rationale: { type: "string" },
133
+ summary: { type: "string" },
134
+ markdown: { type: "string" },
135
+ tags: stringArraySchema(),
136
+ aliases: stringArraySchema()
137
+ })
138
+ }
139
+ });
140
+ }
1
141
  function isOutputTextPart(part) {
2
142
  return (part !== null &&
3
143
  typeof part === "object" &&
@@ -5,7 +145,7 @@ function isOutputTextPart(part) {
5
145
  part.type === "output_text" &&
6
146
  typeof part.text === "string");
7
147
  }
8
- function parseJsonFromOutput(payload) {
148
+ function parseOutputText(payload) {
9
149
  const output = Array.isArray(payload.output) ? payload.output : [];
10
150
  for (const item of output) {
11
151
  if (!item || typeof item !== "object") {
@@ -22,6 +162,82 @@ function parseJsonFromOutput(payload) {
22
162
  }
23
163
  return null;
24
164
  }
165
+ function readReasoningEffort(profile) {
166
+ return typeof profile.metadata.reasoningEffort === "string"
167
+ ? profile.metadata.reasoningEffort
168
+ : null;
169
+ }
170
+ function readVerbosity(profile) {
171
+ return typeof profile.metadata.verbosity === "string"
172
+ ? profile.metadata.verbosity
173
+ : null;
174
+ }
175
+ function buildReasoningConfiguration(profile) {
176
+ const effort = readReasoningEffort(profile);
177
+ return effort ? { effort } : undefined;
178
+ }
179
+ function buildTextConfiguration(options) {
180
+ const text = {};
181
+ const verbosity = readVerbosity(options.profile);
182
+ if (verbosity) {
183
+ text.verbosity = verbosity;
184
+ }
185
+ if (options.format) {
186
+ text.format = options.format;
187
+ }
188
+ return Object.keys(text).length > 0 ? text : undefined;
189
+ }
190
+ function estimateTokens(text) {
191
+ return Math.ceil(text.length / APPROX_CHARS_PER_TOKEN);
192
+ }
193
+ function computeSourceExcerpt(profile, sourceText) {
194
+ const contextWindow = MODEL_CONTEXT_WINDOWS[profile.model] ?? DEFAULT_CONTEXT_WINDOW;
195
+ const inputBudget = Math.max(16_000, contextWindow - RESERVED_RESPONSE_TOKENS);
196
+ const estimatedTokens = estimateTokens(sourceText);
197
+ if (estimatedTokens <= inputBudget) {
198
+ return {
199
+ sourceExcerpt: sourceText,
200
+ estimatedTokens,
201
+ contextWindow,
202
+ inputBudget,
203
+ truncated: false
204
+ };
205
+ }
206
+ const allowedChars = Math.max(8_000, Math.floor(inputBudget * APPROX_CHARS_PER_TOKEN));
207
+ return {
208
+ sourceExcerpt: sourceText.slice(0, allowedChars),
209
+ estimatedTokens,
210
+ contextWindow,
211
+ inputBudget,
212
+ truncated: true
213
+ };
214
+ }
215
+ async function readJsonPayload(response) {
216
+ const payload = (await response.json());
217
+ return payload;
218
+ }
219
+ function readResponseStatus(payload) {
220
+ return typeof payload.status === "string" ? payload.status : null;
221
+ }
222
+ function readResponseId(payload) {
223
+ return typeof payload.id === "string" ? payload.id : null;
224
+ }
225
+ function readResponseError(payload) {
226
+ const error = payload.error;
227
+ if (!error || typeof error !== "object") {
228
+ return null;
229
+ }
230
+ const message = typeof error.message === "string"
231
+ ? error.message
232
+ : null;
233
+ return message;
234
+ }
235
+ function isTerminalBackgroundStatus(status) {
236
+ return (status === "completed" ||
237
+ status === "failed" ||
238
+ status === "cancelled" ||
239
+ status === "incomplete");
240
+ }
25
241
  function normalizeResult(content, input) {
26
242
  if (!content) {
27
243
  return null;
@@ -31,7 +247,8 @@ function normalizeResult(content, input) {
31
247
  return {
32
248
  title: parsed.title?.trim() || input.titleHint || "Imported source",
33
249
  summary: parsed.summary?.trim() || "",
34
- markdown: parsed.markdown?.trim() || input.rawText.trim(),
250
+ markdown: parsed.markdown?.trim() ||
251
+ `# ${parsed.title?.trim() || input.titleHint || "Imported source"}\n\n${parsed.summary?.trim() || "Imported source prepared for Forge review."}\n`,
35
252
  tags: Array.isArray(parsed.tags)
36
253
  ? parsed.tags.filter((tag) => typeof tag === "string")
37
254
  : [],
@@ -52,22 +269,201 @@ function normalizeResult(content, input) {
52
269
  }
53
270
  export class OpenAiResponsesProvider {
54
271
  providerNames = ["openai", "openai-responses", "openai-compatible"];
55
- async compile({ apiKey, profile, input }) {
272
+ async testConnection({ apiKey, profile, logger }) {
273
+ emitDiagnostic(logger, {
274
+ level: "info",
275
+ message: "Testing OpenAI wiki connection.",
276
+ details: {
277
+ scope: "wiki_llm",
278
+ eventKey: "llm_connection_test_start",
279
+ provider: profile.provider,
280
+ baseUrl: profile.baseUrl,
281
+ model: profile.model
282
+ }
283
+ });
284
+ let response;
285
+ try {
286
+ response = await fetch(`${profile.baseUrl.replace(/\/$/, "")}/responses`, {
287
+ method: "POST",
288
+ headers: {
289
+ "content-type": "application/json",
290
+ authorization: `Bearer ${apiKey}`
291
+ },
292
+ body: JSON.stringify({
293
+ model: profile.model,
294
+ input: "Reply with the single word ok.",
295
+ max_output_tokens: 24,
296
+ reasoning: buildReasoningConfiguration(profile),
297
+ text: buildTextConfiguration({ profile })
298
+ })
299
+ });
300
+ }
301
+ catch (error) {
302
+ emitDiagnostic(logger, {
303
+ level: "error",
304
+ message: "OpenAI connection test could not reach the provider.",
305
+ details: {
306
+ scope: "wiki_llm",
307
+ eventKey: "llm_connection_test_transport_error",
308
+ provider: profile.provider,
309
+ baseUrl: profile.baseUrl,
310
+ model: profile.model,
311
+ error: error instanceof Error
312
+ ? {
313
+ name: error.name,
314
+ message: error.message,
315
+ stack: error.stack ?? null
316
+ }
317
+ : String(error)
318
+ }
319
+ });
320
+ throw error;
321
+ }
322
+ if (!response.ok) {
323
+ const message = await response.text();
324
+ emitDiagnostic(logger, {
325
+ level: "error",
326
+ message: `OpenAI connection test failed (${response.status}).`,
327
+ details: {
328
+ scope: "wiki_llm",
329
+ eventKey: "llm_connection_test_failed",
330
+ provider: profile.provider,
331
+ baseUrl: profile.baseUrl,
332
+ model: profile.model,
333
+ status: response.status,
334
+ responseBody: truncate(message)
335
+ }
336
+ });
337
+ throw new Error(`OpenAI connection test failed (${response.status})${message ? `: ${message}` : ""}`);
338
+ }
339
+ const payload = await readJsonPayload(response);
340
+ emitDiagnostic(logger, {
341
+ level: "info",
342
+ message: "OpenAI connection test completed.",
343
+ details: {
344
+ scope: "wiki_llm",
345
+ eventKey: "llm_connection_test_success",
346
+ provider: profile.provider,
347
+ baseUrl: profile.baseUrl,
348
+ model: profile.model,
349
+ outputPreview: truncate(parseOutputText(payload)?.trim() || "ok", 240)
350
+ }
351
+ });
352
+ return {
353
+ outputPreview: parseOutputText(payload)?.trim() || "ok"
354
+ };
355
+ }
356
+ async compile({ apiKey, profile, input, resumeResponseId, logger }) {
357
+ const sourceText = input.rawText.trim();
56
358
  const prompt = [
57
- "You compile user-provided source material into durable Forge wiki memory.",
58
- "Return strict JSON with keys title, summary, markdown, tags, entityProposals, pageUpdateSuggestions, articleCandidates.",
59
- "Forge markdown rules:",
60
- "- Use Markdown headings and concise sections.",
61
- "- Use [[wiki links]] for durable page references when the relationship is meaningful.",
62
- "- Prefer factual, readable, agent-usable writing over decorative prose.",
63
- "- If a concept should become its own page, put it in articleCandidates instead of bloating one page.",
64
- "- If a change belongs in an existing page, emit pageUpdateSuggestions with targetSlug and patchSummary.",
65
- "- If a source implies a Forge entity such as goal, project, habit, strategy, task, or note, emit entityProposals with entityType, title, summary, rationale, confidence, and suggestedFields.",
66
- "- Keep speculative entities conservative; durable pages can be broader than entity proposals.",
359
+ "You convert user-provided source material into reviewable Forge wiki drafts.",
360
+ "You are preparing candidates for human review. Do not publish anything directly.",
361
+ "Return strict JSON with exactly these top-level keys: title, summary, markdown, tags, entityProposals, pageUpdateSuggestions, articleCandidates.",
362
+ "Goal:",
363
+ "- Produce one main overview page in markdown.",
364
+ "- Split durable subtopics into articleCandidates with full draft markdown.",
365
+ "- Propose Forge entities only when the source clearly supports a durable record.",
366
+ "- Suggest page updates only when the source clearly belongs in an existing page instead of a new page.",
367
+ "What Forge expects:",
368
+ "- The main markdown should be a readable overview or anchor page, not a raw source dump.",
369
+ "- articleCandidates should contain real draft wiki pages with their own markdown, not just titles.",
370
+ "- entityProposals must use one of these entityType values only: goal, project, task, habit, strategy, psyche_value, note.",
371
+ "- Use suggestedFields only for fields that truly fit the entity type. Use null for unknown scalar fields and [] for unknown list fields.",
372
+ "- Keep entity proposals conservative. Prefer wiki pages when the source is informative but not actionable enough for a Forge entity.",
373
+ "Forge ontology:",
374
+ "- Forge has two durable knowledge surfaces: wiki pages and structured entities.",
375
+ "- Use wiki pages for rich context, summaries, explanations, relationships, timelines, source synthesis, and themes that are broader than one action item.",
376
+ "- Use entities for operational objects that Forge can track directly: goals, projects, tasks, habits, strategies, psyche values, and durable notes.",
377
+ "- When the same topic needs both explanation and operations, create both: a wiki page for context and an entity proposal for the operational record.",
378
+ "Forge wiki writing rules:",
379
+ "- Use clean Markdown headings and short sections.",
380
+ "- Use [[wiki links]] only for durable concepts, people, projects, places, or pages that should exist in the wiki.",
381
+ "- Prefer factual, compressed, agent-usable writing over decorative prose.",
382
+ "- When the source is a chat, transcript, or message log, do not reproduce turn-by-turn conversation unless the exact wording is the durable artifact.",
383
+ "- For chats and transcripts, extract the durable parts: people, relationships, ongoing projects, commitments, habits, values, decisions, questions, sources, and evidence.",
384
+ "- Merge repetitive back-and-forth into concise summaries.",
385
+ "- Use short quotes only when the exact phrase matters.",
386
+ "How to split pages:",
387
+ "- Keep markdown as the overview page for this source.",
388
+ "- If one topic deserves its own page, put it in articleCandidates with title, slug, summary, rationale, markdown, tags, aliases, and parentSlug.",
389
+ "- Use parentSlug when the draft page clearly belongs under an existing Forge wiki branch such as people, projects, concepts, sources, or chronicle.",
390
+ "- Do not create articleCandidates for every minor mention; only create pages that would be useful to reopen later.",
391
+ "Useful high-level wiki themes:",
392
+ "- people: people, collaborators, family, teams, roles, relationship context.",
393
+ "- projects: bounded initiatives, active efforts, plans, milestones, workstreams.",
394
+ "- concepts: ideas, beliefs, frameworks, principles, definitions, recurring themes.",
395
+ "- sources: books, papers, links, chats, files, interviews, meetings, datasets, media.",
396
+ "- chronicle: dated notes, decisions, turning points, retrospectives, timelines, event sequences.",
397
+ "- values: valued directions, principles, motives, what matters, meaning, purpose.",
398
+ "- practices: routines, rituals, habits, protocols, checklists, playbooks, recipes, methods.",
399
+ "- health: sleep, sport, recovery, symptoms, treatments, biometrics, body-related observations.",
400
+ "- places: homes, cities, venues, clinics, schools, travel locations, recurring environments.",
401
+ "- areas: enduring life domains such as relationships, work, learning, finances, home, family, and community.",
402
+ "- decisions: important choices, tradeoffs, commitments, rules, criteria, and open questions.",
403
+ "- Use these themes as clustering lenses. They are inspired by recurring dimensions in well-being and self-reflection literature: relationships, meaning, accomplishment, growth, health, environment, and life context.",
404
+ "- If the source strongly fits one of these themes but does not justify a Forge entity, create a wiki page draft instead of forcing an entity.",
405
+ "How to propose entities:",
406
+ "- goal: a durable desired outcome with an explicit direction or finish line.",
407
+ "- project: a bounded initiative with a concrete scope, usually linked to a goal.",
408
+ "- task: a single actionable work item, not a broad initiative.",
409
+ "- habit: repeated behavior with a cadence or rule.",
410
+ "- strategy: a repeatable or structured approach linked to a goal, project, or task.",
411
+ "- psyche_value: a durable value, principle, or valued direction that explains what matters to the user.",
412
+ "- note: only when the source implies a durable evidence note that should exist as a record outside the wiki page itself.",
413
+ "Entity proposal rules:",
414
+ "- Never invent IDs.",
415
+ "- Do not propose an entity just because it is mentioned once without durable importance.",
416
+ "- People, concepts, sources, places, and broad life areas are usually wiki pages, not Forge entities.",
417
+ "- Goals should be outcomes, not chores.",
418
+ "- Projects should group multiple steps or phases, not a single errand.",
419
+ "- Tasks should be concrete and actionable enough to do soon.",
420
+ "- Habits should represent repeated behaviors, routines, or standing rules.",
421
+ "- Strategies should represent reusable plans, heuristics, protocols, or decision patterns.",
422
+ "- Psyche values should capture what matters and why, not merely preferences or moods.",
423
+ "- Notes should be evidence-like records, observations, excerpts, or source captures that deserve their own durable object.",
424
+ "- If a project is proposed, include a plausible goal linkage in suggestedFields when the source makes that relationship clear.",
425
+ "- If a habit is proposed, include polarity, frequency, linked goals/projects/tasks, and cadence details when present.",
426
+ "- If a psyche_value is proposed, use valuedDirection, whyItMatters, and linked goals/projects/tasks when present.",
427
+ "- If the source mentions an existing Forge-like object but only adds background context, prefer a pageUpdateSuggestion or wiki page rather than creating a duplicate entity.",
428
+ "What to capture from common source types:",
429
+ "- Chats and message logs: relationship facts, plans, promises, recurring activities, values, concerns, decisions, and candidate projects or habits.",
430
+ "- Articles, books, and notes: concepts, sources, claims, frameworks, quotes worth preserving, and related projects or values.",
431
+ "- Personal logs and journals: chronology, patterns, self-observations, decisions, practices, health notes, relationship dynamics, and values under tension.",
432
+ "- Meeting notes: decisions, owners, projects, tasks, open questions, and source evidence.",
433
+ "Page update rules:",
434
+ "- Only emit pageUpdateSuggestions when the source clearly belongs in an existing page.",
435
+ "- Keep patchSummary concise and specific so Forge can append it safely.",
436
+ "Output discipline:",
437
+ "- Always return every top-level key.",
438
+ "- Always return every nested object field required by the schema.",
439
+ "- Use empty arrays instead of omitting lists.",
440
+ "- Use null instead of omitting unknown scalar fields inside suggestedFields.",
441
+ "- Do not wrap the JSON in markdown fences or prose.",
67
442
  profile.systemPrompt.trim()
68
443
  ]
69
444
  .filter(Boolean)
70
445
  .join("\n");
446
+ const sourcePlan = computeSourceExcerpt(profile, sourceText);
447
+ emitDiagnostic(logger, {
448
+ level: "info",
449
+ message: "Started OpenAI wiki compilation request.",
450
+ details: {
451
+ scope: "wiki_llm",
452
+ eventKey: "llm_compile_start",
453
+ provider: profile.provider,
454
+ baseUrl: profile.baseUrl,
455
+ model: profile.model,
456
+ mimeType: input.mimeType,
457
+ parseStrategy: input.parseStrategy,
458
+ titleHint: input.titleHint,
459
+ rawTextLength: input.rawText.length,
460
+ includesBinary: Boolean(input.binary),
461
+ estimatedInputTokens: sourcePlan.estimatedTokens,
462
+ contextWindow: sourcePlan.contextWindow,
463
+ inputBudget: sourcePlan.inputBudget,
464
+ truncated: sourcePlan.truncated
465
+ }
466
+ });
71
467
  const inputs = [
72
468
  {
73
469
  role: "system",
@@ -77,13 +473,21 @@ export class OpenAiResponsesProvider {
77
473
  const userContent = [
78
474
  {
79
475
  type: "input_text",
80
- text: `Title hint: ${input.titleHint || "none"}\nMime type: ${input.mimeType}\nParse strategy: ${input.parseStrategy}`
476
+ text: [
477
+ `Title hint: ${input.titleHint || "none"}`,
478
+ `Mime type: ${input.mimeType}`,
479
+ `Parse strategy: ${input.parseStrategy}`,
480
+ `Source length: ${sourceText.length} characters`,
481
+ sourcePlan.truncated
482
+ ? `Source was truncated to fit the model context budget (${sourcePlan.inputBudget} estimated input tokens). Focus on durable structure, not verbatim reproduction.`
483
+ : "Source was sent in full."
484
+ ].join("\n")
81
485
  }
82
486
  ];
83
- if (input.rawText.trim()) {
487
+ if (sourceText) {
84
488
  userContent.push({
85
489
  type: "input_text",
86
- text: `Source:\n${input.rawText.slice(0, 24_000)}`
490
+ text: `Source:\n${sourcePlan.sourceExcerpt}`
87
491
  });
88
492
  }
89
493
  if (input.binary &&
@@ -98,63 +502,272 @@ export class OpenAiResponsesProvider {
98
502
  role: "user",
99
503
  content: userContent
100
504
  });
101
- const response = await fetch(`${profile.baseUrl.replace(/\/$/, "")}/responses`, {
102
- method: "POST",
103
- headers: {
104
- "content-type": "application/json",
105
- authorization: `Bearer ${apiKey}`
106
- },
107
- body: JSON.stringify({
108
- model: profile.model,
109
- temperature: 0.2,
110
- input: inputs,
111
- text: {
112
- format: {
113
- type: "json_schema",
114
- name: "forge_wiki_ingest_compilation",
115
- strict: true,
116
- schema: {
117
- type: "object",
118
- additionalProperties: false,
119
- properties: {
120
- title: { type: "string" },
121
- summary: { type: "string" },
122
- markdown: { type: "string" },
123
- tags: {
124
- type: "array",
125
- items: { type: "string" }
126
- },
127
- entityProposals: {
128
- type: "array",
129
- items: { type: "object", additionalProperties: true }
130
- },
131
- pageUpdateSuggestions: {
132
- type: "array",
133
- items: { type: "object", additionalProperties: true }
134
- },
135
- articleCandidates: {
136
- type: "array",
137
- items: { type: "object", additionalProperties: true }
138
- }
139
- },
140
- required: [
141
- "title",
142
- "summary",
143
- "markdown",
144
- "tags",
145
- "entityProposals",
146
- "pageUpdateSuggestions",
147
- "articleCandidates"
148
- ]
505
+ let payload;
506
+ let responseId = resumeResponseId?.trim() || null;
507
+ if (responseId) {
508
+ emitDiagnostic(logger, {
509
+ level: "info",
510
+ message: "Resuming OpenAI wiki compilation from an existing background response.",
511
+ details: {
512
+ scope: "wiki_llm",
513
+ eventKey: "llm_compile_background_resuming",
514
+ provider: profile.provider,
515
+ baseUrl: profile.baseUrl,
516
+ model: profile.model,
517
+ responseId
518
+ }
519
+ });
520
+ const resumeResponse = await fetch(`${profile.baseUrl.replace(/\/$/, "")}/responses/${responseId}`, {
521
+ method: "GET",
522
+ headers: {
523
+ authorization: `Bearer ${apiKey}`
524
+ },
525
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
526
+ });
527
+ if (!resumeResponse.ok) {
528
+ const message = await resumeResponse.text();
529
+ throw new Error(`OpenAI background wiki compilation resume failed: ${resumeResponse.status}${message ? `: ${message}` : ""}`);
530
+ }
531
+ payload = await readJsonPayload(resumeResponse);
532
+ emitDiagnostic(logger, {
533
+ level: "info",
534
+ message: "Forge reattached to the existing OpenAI background wiki compilation job.",
535
+ details: {
536
+ scope: "wiki_llm",
537
+ eventKey: "llm_compile_background_started",
538
+ provider: profile.provider,
539
+ baseUrl: profile.baseUrl,
540
+ model: profile.model,
541
+ responseId,
542
+ status: readResponseStatus(payload),
543
+ resumed: true
544
+ }
545
+ });
546
+ }
547
+ else {
548
+ let createResponse;
549
+ try {
550
+ createResponse = await fetch(`${profile.baseUrl.replace(/\/$/, "")}/responses`, {
551
+ method: "POST",
552
+ headers: {
553
+ "content-type": "application/json",
554
+ authorization: `Bearer ${apiKey}`
555
+ },
556
+ body: JSON.stringify({
557
+ model: profile.model,
558
+ input: inputs,
559
+ store: true,
560
+ background: true,
561
+ prompt_cache_retention: profile.model === "gpt-5.4" ? "24h" : "in_memory",
562
+ prompt_cache_key: `forge-wiki-ingest:${profile.model}:${input.parseStrategy}:${input.mimeType}`,
563
+ reasoning: buildReasoningConfiguration(profile),
564
+ text: buildTextConfiguration({
565
+ profile,
566
+ format: {
567
+ type: "json_schema",
568
+ name: "forge_wiki_ingest_compilation",
569
+ strict: true,
570
+ schema: buildWikiIngestSchema()
571
+ }
572
+ })
573
+ }),
574
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
575
+ });
576
+ }
577
+ catch (error) {
578
+ const finalError = error instanceof Error ? error : new Error(String(error));
579
+ emitDiagnostic(logger, {
580
+ level: "error",
581
+ message: "OpenAI wiki compilation could not reach the provider.",
582
+ details: {
583
+ scope: "wiki_llm",
584
+ eventKey: "llm_compile_transport_error",
585
+ provider: profile.provider,
586
+ baseUrl: profile.baseUrl,
587
+ model: profile.model,
588
+ mimeType: input.mimeType,
589
+ parseStrategy: input.parseStrategy,
590
+ rawTextLength: input.rawText.length,
591
+ estimatedInputTokens: sourcePlan.estimatedTokens,
592
+ truncated: sourcePlan.truncated,
593
+ error: {
594
+ name: finalError.name,
595
+ message: finalError.message,
596
+ stack: finalError.stack ?? null
149
597
  }
150
598
  }
599
+ });
600
+ throw finalError;
601
+ }
602
+ if (!createResponse.ok) {
603
+ const message = await createResponse.text();
604
+ emitDiagnostic(logger, {
605
+ level: "error",
606
+ message: `LLM compilation failed: ${createResponse.status}`,
607
+ details: {
608
+ scope: "wiki_llm",
609
+ eventKey: "llm_compile_failed",
610
+ provider: profile.provider,
611
+ baseUrl: profile.baseUrl,
612
+ model: profile.model,
613
+ mimeType: input.mimeType,
614
+ parseStrategy: input.parseStrategy,
615
+ rawTextLength: input.rawText.length,
616
+ estimatedInputTokens: sourcePlan.estimatedTokens,
617
+ truncated: sourcePlan.truncated,
618
+ status: createResponse.status,
619
+ responseBody: truncate(message)
620
+ }
621
+ });
622
+ throw new Error(`LLM compilation failed: ${createResponse.status}${message ? `: ${message}` : ""}`);
623
+ }
624
+ payload = await readJsonPayload(createResponse);
625
+ responseId = readResponseId(payload);
626
+ if (!responseId) {
627
+ throw new Error("OpenAI background response did not include an id for polling.");
628
+ }
629
+ emitDiagnostic(logger, {
630
+ level: "info",
631
+ message: "OpenAI accepted the wiki compilation job for background processing.",
632
+ details: {
633
+ scope: "wiki_llm",
634
+ eventKey: "llm_compile_background_started",
635
+ provider: profile.provider,
636
+ baseUrl: profile.baseUrl,
637
+ model: profile.model,
638
+ responseId,
639
+ status: readResponseStatus(payload),
640
+ resumed: false
151
641
  }
152
- })
153
- });
154
- if (!response.ok) {
155
- throw new Error(`LLM compilation failed: ${response.status}`);
642
+ });
643
+ }
644
+ let pollCount = 0;
645
+ let consecutivePollFailures = 0;
646
+ while (!isTerminalBackgroundStatus(readResponseStatus(payload))) {
647
+ await new Promise((resolve) => setTimeout(resolve, BACKGROUND_POLL_INTERVAL_MS));
648
+ try {
649
+ const pollResponse = await fetch(`${profile.baseUrl.replace(/\/$/, "")}/responses/${responseId}`, {
650
+ method: "GET",
651
+ headers: {
652
+ authorization: `Bearer ${apiKey}`
653
+ },
654
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
655
+ });
656
+ if (!pollResponse.ok) {
657
+ const message = await pollResponse.text();
658
+ if (pollResponse.status >= 500) {
659
+ consecutivePollFailures += 1;
660
+ emitDiagnostic(logger, {
661
+ level: "warning",
662
+ message: "OpenAI background wiki compilation polling hit a server error. Forge will keep retrying.",
663
+ details: {
664
+ scope: "wiki_llm",
665
+ eventKey: "llm_compile_background_poll_retry",
666
+ provider: profile.provider,
667
+ baseUrl: profile.baseUrl,
668
+ model: profile.model,
669
+ responseId,
670
+ pollCount,
671
+ consecutivePollFailures,
672
+ status: pollResponse.status,
673
+ responseBody: truncate(message)
674
+ }
675
+ });
676
+ continue;
677
+ }
678
+ throw new Error(`OpenAI background wiki compilation polling failed: ${pollResponse.status}${message ? `: ${message}` : ""}`);
679
+ }
680
+ payload = await readJsonPayload(pollResponse);
681
+ pollCount += 1;
682
+ consecutivePollFailures = 0;
683
+ emitDiagnostic(logger, {
684
+ level: "info",
685
+ message: "Polled OpenAI background wiki compilation status.",
686
+ details: {
687
+ scope: "wiki_llm",
688
+ eventKey: "llm_compile_background_polled",
689
+ provider: profile.provider,
690
+ baseUrl: profile.baseUrl,
691
+ model: profile.model,
692
+ responseId,
693
+ pollCount,
694
+ status: readResponseStatus(payload)
695
+ }
696
+ });
697
+ }
698
+ catch (error) {
699
+ const finalError = error instanceof Error ? error : new Error(String(error));
700
+ const isRetriableTransport = finalError.name === "TypeError" ||
701
+ finalError.name === "TimeoutError" ||
702
+ /fetch failed/i.test(finalError.message) ||
703
+ /network/i.test(finalError.message) ||
704
+ /timeout/i.test(finalError.message);
705
+ if (!isRetriableTransport) {
706
+ throw finalError;
707
+ }
708
+ consecutivePollFailures += 1;
709
+ emitDiagnostic(logger, {
710
+ level: "warning",
711
+ message: "OpenAI background wiki compilation polling lost connectivity. Forge will keep retrying.",
712
+ details: {
713
+ scope: "wiki_llm",
714
+ eventKey: "llm_compile_background_poll_retry",
715
+ provider: profile.provider,
716
+ baseUrl: profile.baseUrl,
717
+ model: profile.model,
718
+ responseId,
719
+ pollCount,
720
+ consecutivePollFailures,
721
+ error: {
722
+ name: finalError.name,
723
+ message: finalError.message,
724
+ stack: finalError.stack ?? null
725
+ }
726
+ }
727
+ });
728
+ }
729
+ }
730
+ const finalStatus = readResponseStatus(payload);
731
+ if (finalStatus !== "completed") {
732
+ const errorMessage = readResponseError(payload) ??
733
+ `OpenAI background wiki compilation ended with status ${finalStatus}.`;
734
+ emitDiagnostic(logger, {
735
+ level: "error",
736
+ message: errorMessage,
737
+ details: {
738
+ scope: "wiki_llm",
739
+ eventKey: "llm_compile_background_terminal_error",
740
+ provider: profile.provider,
741
+ baseUrl: profile.baseUrl,
742
+ model: profile.model,
743
+ responseId,
744
+ status: finalStatus
745
+ }
746
+ });
747
+ throw new Error(errorMessage);
156
748
  }
157
- const payload = (await response.json());
158
- return normalizeResult(parseJsonFromOutput(payload), input);
749
+ const content = parseOutputText(payload);
750
+ const result = normalizeResult(content, input);
751
+ emitDiagnostic(logger, {
752
+ level: result ? "info" : "warning",
753
+ message: result
754
+ ? "OpenAI wiki compilation returned structured output."
755
+ : "OpenAI wiki compilation returned output that could not be normalized.",
756
+ details: {
757
+ scope: "wiki_llm",
758
+ eventKey: result ? "llm_compile_success" : "llm_compile_unparseable",
759
+ provider: profile.provider,
760
+ baseUrl: profile.baseUrl,
761
+ model: profile.model,
762
+ responseId,
763
+ estimatedInputTokens: sourcePlan.estimatedTokens,
764
+ truncated: sourcePlan.truncated,
765
+ responsePreview: truncate(content ?? "", 600),
766
+ articleCandidateCount: result?.articleCandidates.length ?? 0,
767
+ entityProposalCount: result?.entityProposals.length ?? 0,
768
+ pageUpdateSuggestionCount: result?.pageUpdateSuggestions.length ?? 0
769
+ }
770
+ });
771
+ return result;
159
772
  }
160
773
  }