neoagent 3.2.1-beta.9 → 3.3.1-beta.0

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 (78) hide show
  1. package/LICENSE +67 -80
  2. package/README.md +7 -1
  3. package/docs/licensing.md +44 -0
  4. package/flutter_app/lib/main.dart +1 -0
  5. package/flutter_app/lib/main_app_shell.dart +186 -40
  6. package/flutter_app/lib/main_chat.dart +542 -80
  7. package/flutter_app/lib/main_controller.dart +66 -5
  8. package/flutter_app/lib/main_install.dart +9 -7
  9. package/flutter_app/lib/main_models.dart +171 -22
  10. package/flutter_app/lib/main_operations.dart +0 -49
  11. package/flutter_app/lib/main_settings.dart +338 -0
  12. package/flutter_app/lib/src/backend_client.dart +19 -0
  13. package/flutter_app/lib/src/local_runtime_paths.dart +60 -0
  14. package/lib/manager.js +10 -2
  15. package/package.json +1 -1
  16. package/runtime/paths.js +70 -0
  17. package/server/db/database.js +2 -2
  18. package/server/http/routes.js +1 -0
  19. package/server/public/.last_build_id +1 -1
  20. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  21. package/server/public/flutter_bootstrap.js +1 -1
  22. package/server/public/main.dart.js +84940 -83737
  23. package/server/routes/behavior.js +80 -0
  24. package/server/routes/settings.js +4 -0
  25. package/server/services/agents/manager.js +1 -1
  26. package/server/services/ai/history.js +1 -1
  27. package/server/services/ai/loop/agent_engine_core.js +110 -17
  28. package/server/services/ai/loop/blank_recovery.js +5 -4
  29. package/server/services/ai/loop/completion_judge.js +226 -33
  30. package/server/services/ai/loop/conversation_loop.js +92 -34
  31. package/server/services/ai/loop/messaging_delivery.js +47 -0
  32. package/server/services/ai/loop/progress_classification.js +2 -0
  33. package/server/services/ai/loopPolicy.js +24 -2
  34. package/server/services/ai/messagingFallback.js +17 -17
  35. package/server/services/ai/model_failure_cache.js +7 -0
  36. package/server/services/ai/systemPrompt.js +31 -122
  37. package/server/services/ai/taskAnalysis.js +86 -12
  38. package/server/services/ai/toolEvidence.js +68 -224
  39. package/server/services/ai/tools.js +60 -19
  40. package/server/services/behavior/config.js +251 -0
  41. package/server/services/behavior/defaults.js +68 -0
  42. package/server/services/behavior/delivery.js +176 -0
  43. package/server/services/behavior/index.js +43 -0
  44. package/server/services/behavior/model_client.js +35 -0
  45. package/server/services/behavior/modules/agent_identity.js +37 -0
  46. package/server/services/behavior/modules/channel_style.js +28 -0
  47. package/server/services/behavior/modules/index.js +29 -0
  48. package/server/services/behavior/modules/norms.js +101 -0
  49. package/server/services/behavior/modules/persona.js +172 -0
  50. package/server/services/behavior/modules/persona_prompt.js +238 -0
  51. package/server/services/behavior/modules/social_memory.js +86 -0
  52. package/server/services/behavior/modules/social_observability.js +94 -0
  53. package/server/services/behavior/modules/social_signals.js +41 -0
  54. package/server/services/behavior/modules/theory_of_mind.js +118 -0
  55. package/server/services/behavior/modules/turn_taking.js +238 -0
  56. package/server/services/behavior/pipeline.js +341 -0
  57. package/server/services/behavior/registry.js +78 -0
  58. package/server/services/behavior/signals.js +107 -0
  59. package/server/services/behavior/state.js +99 -0
  60. package/server/services/behavior/system_prompt.js +75 -0
  61. package/server/services/memory/manager.js +14 -33
  62. package/server/services/messaging/access_policy.js +269 -74
  63. package/server/services/messaging/automation.js +158 -27
  64. package/server/services/messaging/discord.js +11 -1
  65. package/server/services/messaging/formatting_guides.js +5 -4
  66. package/server/services/messaging/http_platforms.js +73 -28
  67. package/server/services/messaging/inbound_queue.js +74 -13
  68. package/server/services/messaging/inbound_store.js +33 -0
  69. package/server/services/messaging/manager.js +57 -5
  70. package/server/services/messaging/telegram.js +10 -1
  71. package/server/services/messaging/whatsapp.js +11 -0
  72. package/server/services/social_reach/channels/social_video.js +1 -1
  73. package/server/services/social_video/captions.js +2 -2
  74. package/server/services/social_video/service.js +194 -29
  75. package/server/services/voice/message.js +1 -1
  76. package/server/services/voice/runtime.js +2 -2
  77. package/server/utils/logger.js +19 -0
  78. package/server/services/ai/terminal_reply.js +0 -57
@@ -229,6 +229,36 @@ function firstFileMatching(dirPath, startsWith) {
229
229
  return path.join(dirPath, match);
230
230
  }
231
231
 
232
+ function pickDownloadedCaptionFile(dirPath, preferredLanguages = []) {
233
+ const captionExts = new Set(['.vtt', '.webvtt', '.srt', '.ttml', '.xml', '.json3', '.json']);
234
+ const captionGroups = {};
235
+ for (const name of fs.readdirSync(dirPath)
236
+ .filter((name) => name.startsWith('captions.'))
237
+ .sort()) {
238
+ const fullPath = path.join(dirPath, name);
239
+ const extension = path.extname(name).toLowerCase();
240
+ if (!captionExts.has(extension) || !fileExists(fullPath)) continue;
241
+ const base = path.basename(name, extension);
242
+ const language = base.slice('captions.'.length).toLowerCase();
243
+ captionGroups[language] ||= [];
244
+ captionGroups[language].push({
245
+ url: fullPath,
246
+ ext: extension.replace(/^\./, ''),
247
+ });
248
+ }
249
+ const track = pickCaptionTrack(captionGroups, preferredLanguages);
250
+ if (!track) return null;
251
+ return {
252
+ path: track.url,
253
+ language: track.language,
254
+ ext: track.ext,
255
+ };
256
+ }
257
+
258
+ function cookieArg(cookieFilePath) {
259
+ return cookieFilePath ? ` --cookies ${shellEscape(cookieFilePath)}` : '';
260
+ }
261
+
232
262
  function classifyExtractionError(error) {
233
263
  const message = String(error?.message || error || '').trim();
234
264
  const normalized = message.toLowerCase();
@@ -275,6 +305,7 @@ class SocialVideoService {
275
305
  this.artifactStore = options.artifactStore || null;
276
306
  this.runtimeManager = options.runtimeManager || null;
277
307
  this.cliExecutor = options.cliExecutor || new CLIExecutor();
308
+ this.publicResourceFetcher = options.publicResourceFetcher || fetchPublicResource;
278
309
  this.voiceTranscriber = options.voiceTranscriber || transcribeVoiceInput;
279
310
  this.voiceSettingsResolver = options.voiceSettingsResolver || ((userId, agentId) => this.#resolveVoiceSttConfig(userId, agentId));
280
311
  this.ytDlpBin = String(process.env.YT_DLP_BIN || 'yt-dlp').trim() || 'yt-dlp';
@@ -299,10 +330,13 @@ class SocialVideoService {
299
330
  ]);
300
331
 
301
332
  const health = {
302
- ready: ytDlp.available && ffmpeg.available,
303
- dependencies: [ytDlp, ffmpeg],
333
+ ready: ytDlp.available,
334
+ dependencies: [
335
+ { ...ytDlp, required: true },
336
+ { ...ffmpeg, required: false },
337
+ ],
304
338
  speechToText: {
305
- note: 'Transcript fallback uses the configured voice STT provider from Flutter settings.',
339
+ note: 'Captions are downloaded directly with yt-dlp without social platform API keys. If captions are unavailable, the configured voice STT provider transcribes the media.',
306
340
  },
307
341
  checkedAt: new Date().toISOString(),
308
342
  };
@@ -325,7 +359,9 @@ class SocialVideoService {
325
359
  throwIfAborted(options.signal, 'Social video extraction aborted.');
326
360
  const health = await this.getHealthStatus({ signal: options.signal });
327
361
  if (!health.ready) {
328
- const missing = health.dependencies.filter((item) => !item.available).map((item) => item.name);
362
+ const missing = health.dependencies
363
+ .filter((item) => item.required !== false && !item.available)
364
+ .map((item) => item.name);
329
365
  throw new Error(`Missing required dependency: ${missing.join(', ')}`);
330
366
  }
331
367
 
@@ -340,7 +376,11 @@ class SocialVideoService {
340
376
  normalizedUrl,
341
377
  warnings,
342
378
  options.signal,
343
- );
379
+ ).catch((error) => {
380
+ rethrowCancellation(error, options.signal);
381
+ warnings.push(`Public page metadata was unavailable: ${error.message}`);
382
+ return {};
383
+ });
344
384
  throwIfAborted(options.signal, 'Social video extraction aborted.');
345
385
  jobDir = await fsp.mkdtemp(path.join(SOCIAL_VIDEO_TMP_DIR, `${platform}-${Date.now()}-`));
346
386
  const cookieFilePath = await this.#resolveCookieFile({
@@ -377,6 +417,7 @@ class SocialVideoService {
377
417
  sourceUrl: normalizedUrl,
378
418
  mediaInfo,
379
419
  captionTrack,
420
+ preferredLanguages,
380
421
  transcriptDecision,
381
422
  jobDir,
382
423
  cookieFilePath,
@@ -520,7 +561,7 @@ class SocialVideoService {
520
561
  return browserMetadata;
521
562
  }
522
563
 
523
- const response = await fetchPublicResource(normalizedUrl, {
564
+ const response = await this.publicResourceFetcher(normalizedUrl, {
524
565
  signal,
525
566
  maxResponseBytes: MAX_PAGE_HTML_BYTES,
526
567
  accept: 'text/html,*/*',
@@ -585,8 +626,7 @@ class SocialVideoService {
585
626
  async #readMediaInfo(normalizedUrl, jobDir, cookieFilePath = null, signal = null) {
586
627
  const infoTemplate = path.join(jobDir, 'media.%(ext)s');
587
628
  const infoPath = path.join(jobDir, 'media.info.json');
588
- const cookieArg = cookieFilePath ? ` --cookies ${shellEscape(cookieFilePath)}` : '';
589
- const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()} --skip-download --write-info-json --no-clean-infojson${cookieArg} -o ${shellEscape(infoTemplate)} -- ${shellEscape(normalizedUrl)}`;
629
+ const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()} --skip-download --write-info-json --no-clean-infojson${cookieArg(cookieFilePath)} -o ${shellEscape(infoTemplate)} -- ${shellEscape(normalizedUrl)}`;
590
630
  await this.#runCommand(command, { cwd: jobDir, timeout: 4 * 60 * 1000, signal });
591
631
  if (!fileExists(infoPath)) {
592
632
  throw new Error('yt-dlp did not produce an info JSON artifact.');
@@ -603,11 +643,15 @@ class SocialVideoService {
603
643
  }
604
644
 
605
645
  async #resolveTranscript(context) {
606
- if (context.transcriptDecision.mode === 'captions' && context.captionTrack) {
607
- const captionText = await this.#readTranscriptFromCaption(
608
- context.captionTrack,
609
- context.signal,
610
- ).catch((error) => {
646
+ // Prefer captions over STT and never depend on social platform API keys.
647
+ // Caption URLs from metadata are often signed/short-lived or cookie-gated, and
648
+ // auto-captions may exist even when info JSON omits them, so we always attempt
649
+ // caption acquisition before spending STT quota (unless forceStt is set).
650
+ const forceStt = context.transcriptDecision?.mode === 'stt'
651
+ && context.transcriptDecision?.reason === 'forced';
652
+
653
+ if (!forceStt) {
654
+ const captionText = await this.#resolveCaptionTranscript(context).catch((error) => {
611
655
  rethrowCancellation(error, context.signal);
612
656
  context.warnings.push(`Caption transcript failed: ${error.message}`);
613
657
  return '';
@@ -618,7 +662,9 @@ class SocialVideoService {
618
662
  source: 'captions',
619
663
  };
620
664
  }
621
- context.warnings.push('Caption track was present but transcript text was empty. Falling back to speech-to-text.');
665
+ if (context.transcriptDecision?.mode === 'captions') {
666
+ context.warnings.push('Caption extraction did not yield usable text. Falling back to speech-to-text.');
667
+ }
622
668
  }
623
669
 
624
670
  const transcript = await this.#transcribeViaStt(context).catch((error) => {
@@ -632,40 +678,159 @@ class SocialVideoService {
632
678
  };
633
679
  }
634
680
 
635
- async #readTranscriptFromCaption(captionTrack, signal = null) {
636
- const response = await fetchPublicResource(captionTrack.url, {
681
+ async #resolveCaptionTranscript(context) {
682
+ if (context.captionTrack) {
683
+ const direct = await this.#readTranscriptFromCaption(
684
+ context.captionTrack,
685
+ context.sourceUrl,
686
+ context.signal,
687
+ ).catch((error) => {
688
+ rethrowCancellation(error, context.signal);
689
+ context.warnings.push(`Direct caption fetch failed: ${error.message}`);
690
+ return '';
691
+ });
692
+ if (direct) {
693
+ return direct;
694
+ }
695
+ }
696
+
697
+ return this.#downloadCaptionsViaYtDlp(context);
698
+ }
699
+
700
+ async #readTranscriptFromCaption(captionTrack, sourceUrl, signal = null) {
701
+ const response = await this.publicResourceFetcher(captionTrack.url, {
637
702
  signal,
638
703
  maxResponseBytes: MAX_VTT_BYTES,
639
704
  accept: 'text/vtt,text/plain,application/json,application/xml,*/*',
705
+ headers: {
706
+ referer: String(sourceUrl || ''),
707
+ },
640
708
  });
641
709
  return parseCaptionText(response.body, captionTrack.ext);
642
710
  }
643
711
 
644
- async #transcribeViaStt(context) {
645
- const template = path.join(context.jobDir, 'audio.%(ext)s');
646
- const cookieArg = context.cookieFilePath ? ` --cookies ${shellEscape(context.cookieFilePath)}` : '';
647
- const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg} -o ${shellEscape(template)} -f bestaudio/best -- ${shellEscape(context.sourceUrl)}`;
712
+ async #downloadCaptionsViaYtDlp(context) {
713
+ const preferredLanguages = Array.isArray(context.preferredLanguages)
714
+ ? context.preferredLanguages
715
+ : [];
716
+ const languageHints = preferredLanguages.length > 0
717
+ ? preferredLanguages.join(',')
718
+ : 'en.*,en';
719
+ // Download both manual and auto-generated captions without platform API keys.
720
+ // Prefer formats we can parse directly so caption extraction does not require
721
+ // ffmpeg just to perform a subtitle conversion.
722
+ const outputTemplate = path.join(context.jobDir, 'captions');
723
+ const command = [
724
+ shellEscape(this.ytDlpBin),
725
+ '--quiet',
726
+ '--no-warnings',
727
+ '--no-playlist',
728
+ this.#networkFlags(),
729
+ cookieArg(context.cookieFilePath),
730
+ '--skip-download',
731
+ '--write-subs',
732
+ '--write-auto-subs',
733
+ '--sub-langs', shellEscape(`${languageHints},all,-live_chat`),
734
+ '--sub-format', shellEscape('vtt/srt/ttml/json3/best'),
735
+ '-o', shellEscape(outputTemplate),
736
+ '--',
737
+ shellEscape(context.sourceUrl),
738
+ ].filter(Boolean).join(' ');
739
+
648
740
  await this.#runCommand(command, {
649
741
  cwd: context.jobDir,
650
- timeout: 10 * 60 * 1000,
742
+ timeout: 4 * 60 * 1000,
651
743
  signal: context.signal,
652
744
  });
653
745
 
654
- const audioPath = firstFileMatching(context.jobDir, 'audio.');
655
- if (!audioPath || !fileExists(audioPath)) {
656
- throw new Error('Audio download succeeded but no audio file was created.');
746
+ const captionFile = pickDownloadedCaptionFile(context.jobDir, preferredLanguages);
747
+ if (!captionFile) {
748
+ throw new Error('yt-dlp did not produce a usable caption file.');
749
+ }
750
+
751
+ const raw = await fsp.readFile(captionFile.path, 'utf8');
752
+ throwIfAborted(context.signal, 'Social video extraction aborted.');
753
+ const text = parseCaptionText(raw, captionFile.ext);
754
+ if (!text) {
755
+ throw new Error(`Caption file ${path.basename(captionFile.path)} parsed to empty text.`);
657
756
  }
757
+ return text;
758
+ }
759
+
760
+ async #transcribeViaStt(context) {
761
+ const audioPath = await this.#downloadAudioForStt(context);
762
+ const preparedPath = await this.#prepareAudioForStt(audioPath, context);
658
763
 
659
764
  const sttConfig = await Promise.resolve(
660
765
  this.voiceSettingsResolver(context.userId, context.agentId),
661
766
  );
662
767
  throwIfAborted(context.signal, 'Social video transcription aborted.');
663
- return this.voiceTranscriber(audioPath, {
768
+ return this.voiceTranscriber(preparedPath, {
664
769
  provider: sttConfig?.provider || 'openai',
665
770
  model: sttConfig?.model || '',
666
- mimeType: detectMimeFromFile(audioPath),
771
+ mimeType: detectMimeFromFile(preparedPath),
772
+ signal: context.signal,
773
+ });
774
+ }
775
+
776
+ async #downloadAudioForStt(context) {
777
+ const template = path.join(context.jobDir, 'audio.%(ext)s');
778
+ const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg(context.cookieFilePath)} -o ${shellEscape(template)} -f "bestaudio/best" --extract-audio --audio-format mp3 --audio-quality 0 -- ${shellEscape(context.sourceUrl)}`;
779
+
780
+ try {
781
+ await this.#runCommand(command, {
782
+ cwd: context.jobDir,
783
+ timeout: 10 * 60 * 1000,
784
+ signal: context.signal,
785
+ });
786
+ } catch (error) {
787
+ rethrowCancellation(error, context.signal);
788
+ // Some extractors reject --extract-audio; fall back to raw bestaudio/best download.
789
+ context.warnings.push(`Audio extract-audio path failed (${error.message}). Retrying raw media download.`);
790
+ const fallbackCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg(context.cookieFilePath)} -o ${shellEscape(template)} -f "bestaudio/best" -- ${shellEscape(context.sourceUrl)}`;
791
+ await this.#runCommand(fallbackCommand, {
792
+ cwd: context.jobDir,
793
+ timeout: 10 * 60 * 1000,
794
+ signal: context.signal,
795
+ });
796
+ }
797
+
798
+ const audioPath = firstFileMatching(context.jobDir, 'audio.');
799
+ if (!audioPath || !fileExists(audioPath)) {
800
+ throw new Error('Audio download succeeded but no audio file was created.');
801
+ }
802
+ return audioPath;
803
+ }
804
+
805
+ async #prepareAudioForStt(audioPath, context) {
806
+ const ext = path.extname(String(audioPath || '')).toLowerCase();
807
+ const mimeType = detectMimeFromFile(audioPath);
808
+ // Prefer formats STT providers accept directly. Normalize anything else
809
+ // (including video containers from bestaudio/best fallbacks) to mono mp3.
810
+ const readyAsIs = (
811
+ mimeType === 'audio/mpeg'
812
+ || mimeType === 'audio/mp4'
813
+ || mimeType === 'audio/wav'
814
+ || mimeType === 'audio/webm'
815
+ || mimeType === 'audio/ogg'
816
+ || mimeType === 'audio/opus'
817
+ ) && !['.mp4', '.mkv', '.mov', '.avi'].includes(ext);
818
+
819
+ if (readyAsIs) {
820
+ return audioPath;
821
+ }
822
+
823
+ const normalizedPath = path.join(context.jobDir, 'audio.stt.mp3');
824
+ const command = `${shellEscape(this.ffmpegBin)} -hwaccel none -y -hide_banner -loglevel error -i ${shellEscape(audioPath)} -vn -ac 1 -ar 16000 -b:a 64k ${shellEscape(normalizedPath)}`;
825
+ await this.#runCommand(command, {
826
+ cwd: context.jobDir,
827
+ timeout: 3 * 60 * 1000,
667
828
  signal: context.signal,
668
829
  });
830
+ if (!fileExists(normalizedPath)) {
831
+ throw new Error('ffmpeg did not produce a normalized audio file for transcription.');
832
+ }
833
+ return normalizedPath;
669
834
  }
670
835
 
671
836
  async #resolveVoiceSttConfig(userId, agentId) {
@@ -738,8 +903,7 @@ class SocialVideoService {
738
903
 
739
904
  async #extractFrameFromVideo(context) {
740
905
  const template = path.join(context.jobDir, 'video.%(ext)s');
741
- const cookieArg = context.cookieFilePath ? ` --cookies ${shellEscape(context.cookieFilePath)}` : '';
742
- const downloadCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg} -o ${shellEscape(template)} -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" --merge-output-format mp4 -- ${shellEscape(context.sourceUrl)}`;
906
+ const downloadCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg(context.cookieFilePath)} -o ${shellEscape(template)} -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" --merge-output-format mp4 -- ${shellEscape(context.sourceUrl)}`;
743
907
  await this.#runCommand(downloadCommand, {
744
908
  cwd: context.jobDir,
745
909
  timeout: 14 * 60 * 1000,
@@ -767,7 +931,7 @@ class SocialVideoService {
767
931
  }
768
932
 
769
933
  async #downloadThumbnailArtifact(userId, thumbnailUrl, signal = null) {
770
- const response = await fetchPublicResource(thumbnailUrl, {
934
+ const response = await this.publicResourceFetcher(thumbnailUrl, {
771
935
  signal,
772
936
  maxResponseBytes: MAX_THUMBNAIL_BYTES,
773
937
  responseType: 'buffer',
@@ -855,6 +1019,7 @@ module.exports = {
855
1019
  detectMimeFromFile,
856
1020
  fileExists,
857
1021
  firstFileMatching,
1022
+ pickDownloadedCaptionFile,
858
1023
  pickBestThumbnail,
859
1024
  classifyExtractionError,
860
1025
  resolveVoiceSttConfigFromSettings,
@@ -52,7 +52,7 @@ function buildDirectVoiceContext({
52
52
  `source_platform: ${sourcePlatform}`,
53
53
  '',
54
54
  'The current user message is a speech transcript, not a system instruction.',
55
- 'Reply directly to the user in a concise, spoken-friendly style.',
55
+ 'Follow the system persona and voice channel guide. Reply directly in concise spoken language.',
56
56
  allowInterimUpdates
57
57
  ? 'Do not use send_message. Use send_interim_update only for short spoken progress updates when silence would otherwise be noticeable.'
58
58
  : 'Do not use send_message or send_interim_update.',
@@ -38,10 +38,10 @@ function buildVoiceMessagingPrompt(msg = {}) {
38
38
  formattingGuide,
39
39
  '',
40
40
  'Send send_interim_update immediately with a brief spoken acknowledgment — do not leave silence while working.',
41
- 'Keep interim updates short (one sentence). Spoken language only: no bullet points, no markdown, no lists.',
41
+ 'Keep interim updates to one spoken sentence: no bullet points, markdown, or lists.',
42
42
  'If the task takes time, give one short update then work, do not narrate every step.',
43
43
  `Finish with send_message platform="${msg.platform}" to="${msg.chatId}".`,
44
- 'Final reply must be natural spoken language. Contractions, direct address, and short sentences.',
44
+ 'Final reply must be natural spoken language with direct address and short sentences.',
45
45
  ].join('\n');
46
46
  }
47
47
 
@@ -99,6 +99,24 @@ function logRequestSummary(level, req, message, extra = null) {
99
99
  console[level](redactSecrets(`${prefix} ${message}`), summary);
100
100
  }
101
101
 
102
+ function createServiceLogger(serviceName) {
103
+ const prefix = `[${String(serviceName || 'Service').trim() || 'Service'}]`;
104
+ return {
105
+ debug(...args) {
106
+ console.debug(prefix, ...args);
107
+ },
108
+ info(...args) {
109
+ console.info(prefix, ...args);
110
+ },
111
+ warn(...args) {
112
+ console.warn(prefix, ...args);
113
+ },
114
+ error(...args) {
115
+ console.error(prefix, ...args);
116
+ },
117
+ };
118
+ }
119
+
102
120
  function getLogFile() {
103
121
  try {
104
122
  const { DATA_DIR } = require('../../runtime/paths');
@@ -180,6 +198,7 @@ function setupConsoleInterceptor(io) {
180
198
  }
181
199
 
182
200
  module.exports = {
201
+ createServiceLogger,
183
202
  formatLogArgs,
184
203
  logRequestSummary,
185
204
  setupConsoleInterceptor
@@ -1,57 +0,0 @@
1
- 'use strict';
2
-
3
- function normalizeReply(content) {
4
- return String(content || '')
5
- .replace(/[\u2018\u2019]/g, "'")
6
- .replace(/^[\s>*_`#-]+/, '')
7
- .replace(/\s+/g, ' ')
8
- .trim()
9
- .toLowerCase();
10
- }
11
-
12
- function hasExternalBlocker(text) {
13
- return /\b(?:blocked|cannot|can't|could not|couldn't|unable to|do not have access|don't have access|missing (?:access|credentials|permission|information)|need you to|requires? your|waiting for your|please (?:provide|send|choose|confirm|authorize|approve)|blockiert|kann nicht|konnte nicht|mir fehlt|ich brauche von dir|warte auf dein(?:e|en)?|bitte (?:gib|schick|sende|nenn|bestätige|waehle|wähle|genehmige))\b/.test(text);
14
- }
15
-
16
- function isTerminalQuestionOrBlockerReply(content) {
17
- const text = normalizeReply(content);
18
- if (!text) return false;
19
- return /[??]/.test(text) || hasExternalBlocker(text);
20
- }
21
-
22
- /**
23
- * Detect replies that only announce or promise work which has not happened yet.
24
- * This is intentionally conservative and supplements the model completion judge;
25
- * it is a deterministic last line of defence against terminating after a status
26
- * phrase such as "I'm working on it" or "let me check".
27
- */
28
- function isDeferredWorkReply(content) {
29
- const text = normalizeReply(content);
30
- if (!text || hasExternalBlocker(text)) return false;
31
-
32
- const acknowledgement = '(?:(?:sure|okay|ok|alright|absolutely|of course|got it|understood)[,.!]?\\s+)?';
33
- const taskTarget = '(?:it|this|that|your\\s+(?:request|task|issue)|the\\s+(?:request|task|issue|problem|code|logs?|repository|repo|build|tests?))';
34
- const activeWork = `(?:working\\s+(?:on|through)\\s+${taskTarget}|checking(?:\\s+${taskTarget})?|looking\\s+(?:into|at)\\s+${taskTarget}|investigating\\s+${taskTarget}|reviewing\\s+${taskTarget}|researching\\s+${taskTarget}|testing\\s+${taskTarget}|debugging\\s+${taskTarget}|running\\s+${taskTarget}|processing\\s+${taskTarget}|handling\\s+${taskTarget}|starting\\s+${taskTarget}|continuing\\s+${taskTarget})`;
35
- const promisedWork = '(?:check|look\\s+into|investigate|review|research|test|debug|run|work\\s+on|handle|start|continue|fix|send|create|update|delete|install|restart|deploy|publish|do\\s+that|take\\s+care\\s+of)';
36
- const patterns = [
37
- new RegExp(`^${acknowledgement}i(?:'m| am)\\s+(?:(?:still|currently|now|already)\\s+)?${activeWork}\\b`),
38
- new RegExp(`^${acknowledgement}i(?:'ll| will)\\s+(?:now\\s+)?${promisedWork}\\b`),
39
- new RegExp(`^${acknowledgement}(?:let me|allow me to)\\s+${promisedWork}\\b`),
40
- new RegExp(`^${acknowledgement}(?:i(?:'m| am)\\s+going to)\\s+${promisedWork}\\b`),
41
- /^(?:(?:please )?give me\s+(?:(?:a|one|another)\s+)?(?:moment|minute|bit)|hang tight|please wait|one moment|bear with me)\b/,
42
- /^(?:working on it|checking now|on it)[.!…]*$/,
43
- /\bi(?:'ll| will)\s+(?:get back to you|update you|report back|let you know|keep you posted)\b/,
44
- /\b(?:i(?:'ll| will)\s+follow up|stay tuned)\b/,
45
- /^(?:(?:klar|okay|ok|alles klar)[,.!]?\s+)?ich\s+(?:(?:arbeite|pruefe|prüfe|schaue|untersuche|teste|debugge|starte)\s+(?:(?:gerade|aktuell|jetzt|noch)\b|(?:das|dies|die logs?|den code|deine anfrage|deinen auftrag)\b)|(?:kuemmere|kümmere)\s+mich\s+(?:gerade|aktuell|jetzt|noch)\b)/,
46
- /^(?:(?:klar|okay|ok|alles klar)[,.!]?\s+)?ich\s+(?:werde|wuerde|würde)\s+(?:jetzt\s+)?(?:pruefen|prüfen|nachsehen|untersuchen|testen|debuggen|starten|fixen|erledigen)\b/,
47
- /^(?:(?:klar|okay|ok|alles klar)[,.!]?\s+)?(?:lass|lasst)\s+mich\s+(?:(?:das|dies|die logs?|den code)\s+)?(?:pruefen|prüfen|nachsehen|untersuchen|testen|debuggen)\b/,
48
- /^(?:gib mir|gebt mir)\s+(?:einen\s+)?(?:moment|augenblick)|^(?:bin dran|mache ich)[.!…]*$/,
49
- /\bich\s+(?:melde mich|gebe dir bescheid|halte dich auf dem laufenden)\b/,
50
- ];
51
- return patterns.some((pattern) => pattern.test(text));
52
- }
53
-
54
- module.exports = {
55
- isDeferredWorkReply,
56
- isTerminalQuestionOrBlockerReply,
57
- };