@aion0/forge 0.10.33 → 0.10.35

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/RELEASE_NOTES.md CHANGED
@@ -1,23 +1,12 @@
1
- # Forge v0.10.33
1
+ # Forge v0.10.35
2
2
 
3
- Released: 2026-06-02
3
+ Released: 2026-06-03
4
4
 
5
- ## Changes since v0.10.32
6
-
7
- ### Documentation
8
- - docs: update help-docs for activity pill, marketplace, usage move, watch builtins
5
+ ## Changes since v0.10.34
9
6
 
10
7
  ### Other
11
- - refactor(marketplace): Pipelines first + default landing
12
- - refactor(dashboard): move Activity pill next to Automation tab
13
- - refactor(dashboard): promote Chat (web) + open standalone routes in new tab
14
- - refactor(dashboard): promote Settings, add icons to user menu rows
15
- - refactor(dashboard): move Usage into user menu next to Monitor/Login Status
16
- - refactor(marketplace): split category dropdown by group
17
- - perf(pipeline-view): invalidate cache after mutations
18
- - fix(activity): view link uses forge:navigate event
19
- - perf(pipeline-view): module-level SWR cache for meta + per-workflow runs
20
- - feat(activity): top-right Activity pill — running pipelines + upcoming schedules
8
+ - chat: namespace gating connector tools load on demand via connector_open
9
+ - DocTerminal: New/Resume buttons use configured agent, not hardcoded claude
21
10
 
22
11
 
23
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.32...v0.10.33
12
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.34...v0.10.35
@@ -123,25 +123,47 @@ export default function ActivityPanel() {
123
123
  const runningCount = summary?.running.length ?? 0;
124
124
  const upcomingCount = summary?.upcoming.length ?? 0;
125
125
  const recentFailed = (summary?.recent ?? []).filter((r) => r.status === 'failed').length;
126
+ const hasAny = runningCount + upcomingCount + recentFailed > 0;
126
127
 
127
- // Pill chips
128
- const chips: string[] = [];
129
- if (runningCount) chips.push(`▶${runningCount}`);
130
- if (upcomingCount) chips.push(`⏰${upcomingCount}`);
131
- if (!chips.length) chips.push('✓');
128
+ // Pill border tint picks the most urgent state:
129
+ // failed (red) > running (blue) > else dim.
130
+ const borderTint = recentFailed > 0
131
+ ? 'border-red-500/50'
132
+ : runningCount > 0
133
+ ? 'border-blue-500/50'
134
+ : 'border-[var(--border)]';
132
135
 
133
136
  return (
134
137
  <div className="relative" ref={panelRef}>
135
138
  <button
136
139
  onClick={() => setOpen((o) => !o)}
137
- className={`text-[10px] px-2 py-0.5 rounded border border-[var(--border)] flex items-center gap-1.5
138
- ${runningCount > 0 ? 'text-blue-400 border-blue-500/50' : 'text-[var(--text-secondary)]'}
139
- hover:text-[var(--text-primary)]`}
140
- title="Activity — running pipelines + upcoming schedules"
140
+ className={`text-[10px] px-2 py-0.5 rounded border ${borderTint} flex items-center gap-2.5
141
+ text-[var(--text-secondary)] hover:text-[var(--text-primary)]`}
142
+ title="Activity — running pipelines · upcoming schedules · recent failures"
141
143
  >
142
- <span className="font-medium">{chips.join(' ')}</span>
143
- {recentFailed > 0 && (
144
- <span className="text-red-400 text-[9px]" title={`${recentFailed} recently failed`}>!{recentFailed}</span>
144
+ {!hasAny ? (
145
+ <span className="text-[var(--text-secondary)]">✓</span>
146
+ ) : (
147
+ <>
148
+ {runningCount > 0 && (
149
+ <span className="inline-flex items-baseline text-blue-400" title={`${runningCount} running`}>
150
+ <span className="text-[7px] mr-0.5">●</span>
151
+ <span className="font-semibold tabular-nums">{runningCount}</span>
152
+ </span>
153
+ )}
154
+ {upcomingCount > 0 && (
155
+ <span className="inline-flex items-baseline text-[var(--text-secondary)]" title={`${upcomingCount} upcoming`}>
156
+ <span className="text-[8px] mr-0.5">◷</span>
157
+ <span className="font-semibold tabular-nums">{upcomingCount}</span>
158
+ </span>
159
+ )}
160
+ {recentFailed > 0 && (
161
+ <span className="inline-flex items-baseline text-red-400" title={`${recentFailed} recently failed`}>
162
+ <span className="text-[8px] mr-0.5">✕</span>
163
+ <span className="font-semibold tabular-nums">{recentFailed}</span>
164
+ </span>
165
+ )}
166
+ </>
145
167
  )}
146
168
  </button>
147
169
 
@@ -166,13 +166,13 @@ export default function DocTerminal({ docRoot, agent }: { docRoot: string; agent
166
166
  </span>
167
167
  <div className="ml-auto flex items-center gap-1">
168
168
  <button
169
- onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && claude${sf}`); }}
169
+ onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && ${agentCmdRef.current}${sf}`); }}
170
170
  className="text-[10px] px-2 py-0.5 text-[var(--accent)] hover:bg-[#2a2a4a] rounded"
171
171
  >
172
172
  New
173
173
  </button>
174
174
  <button
175
- onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && claude -c${sf}`); }}
175
+ onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && ${agentCmdRef.current} -c${sf}`); }}
176
176
  className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded"
177
177
  >
178
178
  Resume
@@ -732,7 +732,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
732
732
  {installTarget.skill === itemName && installTarget.show && (
733
733
  <>
734
734
  <div className="fixed inset-0 z-40" onClick={() => setInstallTarget({ skill: '', show: false })} />
735
- <div className="absolute right-0 top-7 w-[200px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
735
+ <div className="absolute right-0 top-7 w-[200px] max-h-[60vh] overflow-y-auto bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
736
736
  <button
737
737
  onClick={async () => {
738
738
  const res = await fetch('/api/skills/local', { method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -743,7 +743,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
743
743
  setInstallTarget({ skill: '', show: false });
744
744
  fetchSkills();
745
745
  }}
746
- className="w-full text-left text-[10px] px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
746
+ className="w-full text-left text-[10px] px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)] sticky top-0 bg-[var(--bg-secondary)]"
747
747
  >Global (~/.claude)</button>
748
748
  <div className="border-t border-[var(--border)] my-0.5" />
749
749
  {projects.map(p => (
@@ -792,10 +792,10 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
792
792
  {installTarget.skill === skill.name && installTarget.show && (
793
793
  <>
794
794
  <div className="fixed inset-0 z-40" onClick={() => setInstallTarget({ skill: '', show: false })} />
795
- <div className="absolute right-0 top-7 w-[180px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
795
+ <div className="absolute right-0 top-7 w-[180px] max-h-[60vh] overflow-y-auto bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
796
796
  <button
797
797
  onClick={() => install(skill.name, 'global')}
798
- className={`w-full text-left text-[10px] px-3 py-1.5 hover:bg-[var(--bg-tertiary)] ${
798
+ className={`w-full text-left text-[10px] px-3 py-1.5 hover:bg-[var(--bg-tertiary)] sticky top-0 bg-[var(--bg-secondary)] ${
799
799
  skill.installedGlobal ? 'text-[var(--green)]' : 'text-[var(--text-primary)]'
800
800
  }`}
801
801
  >
@@ -207,7 +207,50 @@ export function resolveProvider(sessionProvider: string | null, sessionModel: st
207
207
  };
208
208
  }
209
209
 
210
- function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTIN_TOOL_DEFS, sessionSystemPrompt: string | null): string {
210
+ /**
211
+ * Build the connector catalog block — one entry per installed connector,
212
+ * showing what it can do. The LLM reads this to decide whether to call
213
+ * connector_open(<id>) before reaching for a tool.
214
+ *
215
+ * Prefers `catalog_summary` from the manifest (curated 2-4 line English
216
+ * blurb). Falls back to first 5 tool names if absent, so older manifests
217
+ * still produce something usable.
218
+ */
219
+ function buildConnectorCatalog(openSet: Set<string>): string[] {
220
+ const lines: string[] = [];
221
+ for (const inst of listInstalledConnectors()) {
222
+ if (!inst.enabled) continue;
223
+ const def = inst.definition;
224
+ let toolCount = 0;
225
+ const sampleNames: string[] = [];
226
+ for (const entry of getConnectorEntries(def)) {
227
+ for (const tname of Object.keys(entry.tools || {})) {
228
+ toolCount += 1;
229
+ if (sampleNames.length < 5) sampleNames.push(tname);
230
+ }
231
+ }
232
+ const status = openSet.has(def.id) ? ' [OPEN]' : '';
233
+ const summary = (def.catalog_summary || '').trim();
234
+ if (summary) {
235
+ lines.push(`▸ ${def.id}${status} (${toolCount} tools):`);
236
+ for (const ln of summary.split('\n')) {
237
+ const trimmed = ln.trim();
238
+ if (trimmed) lines.push(` ${trimmed}`);
239
+ }
240
+ } else {
241
+ const sample = sampleNames.join(', ');
242
+ lines.push(`▸ ${def.id}${status}: ${toolCount} tools (e.g. ${sample}${toolCount > 5 ? ', …' : ''})`);
243
+ }
244
+ }
245
+ return lines;
246
+ }
247
+
248
+ function buildSystemPrompt(
249
+ openConnectorTools: LlmTool[],
250
+ openSet: Set<string>,
251
+ builtinDefs: typeof BUILTIN_TOOL_DEFS,
252
+ sessionSystemPrompt: string | null,
253
+ ): string {
211
254
  const now = new Date().toISOString();
212
255
 
213
256
  // Inject a brief Forge context block (project names only) so the LLM can
@@ -227,8 +270,10 @@ function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTI
227
270
  `Current time: ${now}`,
228
271
  '',
229
272
  'Tool usage — IMPORTANT:',
230
- '- If the user mentions a system name (e.g. "teams", "mantis", "gitlab", "pmdb") even casually like "在 teams 中..." / "from mantis" you MUST attempt the matching connector tool FIRST.',
231
- ' Don\'t explain how to do something manually before trying the tool. The tools below run inside the user\'s actual logged-in browser session they CAN do things you might think only the user can do manually.',
273
+ '- Connector tools (mantis.*, gitlab.*, nac.*, tp.*, etc.) are NOT in your active tool list by default. The "Connector catalog" below shows what each connector can do.',
274
+ ' Call connector_open({name: "<id>"}) FIRST to load a connector its tools become callable on your next turn. The open set resets at every new user message, so re-open as the user pivots topics.',
275
+ '- If the user mentions a system name (e.g. "teams", "mantis", "gitlab", "pmdb") — even casually like "在 teams 中..." / "from mantis" — open that connector and use its tools.',
276
+ ' Don\'t explain how to do something manually before trying the tool. The connector tools run inside the user\'s actual logged-in browser session — they CAN do things you might think only the user can do manually.',
232
277
  '- For Teams in particular: send_message can target any chat by name; if the chat doesn\'t exist yet, the tool will return a specific error and THEN you can advise. Don\'t pre-judge.',
233
278
  '- If a tool call fails, read its error carefully — it usually tells you what to fix (wrong arg, missing setting, login required). Retry with the fix. Only give up after the tool explicitly says it cannot do the task.',
234
279
  '- For trigger_pipeline / dispatch_task: when the user names a "project" (e.g. "FortiNAC"), pass it as input.project verbatim. The names in the "Forge projects" list below ARE the valid values. Call list_forge_context only if you need paths / agents / skills.',
@@ -250,14 +295,16 @@ function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTI
250
295
  lines.push('', `Forge projects (valid input.project values): ${projectNames.join(', ')}`);
251
296
  }
252
297
 
253
- if (connectorTools.length > 0) {
254
- lines.push('', 'Connector tools available:');
255
- for (const t of connectorTools) {
256
- lines.push(`- ${t.name}: ${t.description.slice(0, 100)}`);
298
+ const catalog = buildConnectorCatalog(openSet);
299
+ if (catalog.length > 0) {
300
+ lines.push('', 'Connector catalog — call connector_open({name}) to load tools:');
301
+ lines.push(...catalog);
302
+ if (openConnectorTools.length > 0) {
303
+ lines.push('', `Currently open connectors (${[...openSet].join(', ')}) — their tools ARE in your active tool list this turn.`);
257
304
  }
258
305
  }
259
306
  if (builtinDefs.length > 0) {
260
- lines.push('', 'Builtin tools:');
307
+ lines.push('', 'Builtin tools (always available):');
261
308
  for (const t of builtinDefs) {
262
309
  lines.push(`- ${t.name}: ${t.description.slice(0, 100)}`);
263
310
  }
@@ -431,7 +478,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
431
478
  memStore.listBlocks({ pinned: true, scope: 'both' }),
432
479
  memStore.listBlocks({ scope: 'both' }),
433
480
  memStore.search(args.userText, 8),
434
- buildMemoryContext({ store: memStore, currentUserMessage: args.userText }),
481
+ buildMemoryContext({ store: memStore, currentUserMessage: args.userText, currentSessionId: args.sessionId }),
435
482
  ]);
436
483
  const pinnedBlocks = bp.status === 'fulfilled' ? bp.value : [];
437
484
  const allBlocks = ba.status === 'fulfilled' ? ba.value : [];
@@ -460,36 +507,36 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
460
507
  });
461
508
  }
462
509
 
463
- let connectorTools = buildConnectorTools();
464
-
465
- // ── Narrowing: if the user named connectors (/teams, "in mantis", ...)
466
- // restrict the tool list so the LLM can't wander to unrelated connectors.
467
- // Strong (slash-prefix) signals also emit a directive in the system prompt
468
- // so the model treats it as a command, not a hint.
469
- const allConnectorIds = [...new Set(connectorTools.map((t) => t.name.split('.')[0]!))];
510
+ // ── Full connector tool inventory ────────────────────────────────
511
+ // We build the full set ONCE. The active `tools` field sent to the LLM
512
+ // is computed per iteration by filtering to the "open set" (connectors
513
+ // the LLM has opened via connector_open OR the user explicitly named).
514
+ const allConnectorTools = buildConnectorTools();
515
+ const allConnectorIds = [...new Set(allConnectorTools.map((t) => t.name.split('.')[0]!))];
470
516
  const pluginCatalog = allConnectorIds.map((id) => {
471
517
  const def = getConnector(id);
472
518
  return { id, name: def?.name };
473
519
  });
520
+
521
+ // User-mention auto-open: if the user's message names a connector (slash
522
+ // or bare), seed the open set so the LLM doesn't need an extra
523
+ // connector_open round-trip for the obvious cases. Strong signals
524
+ // (/connector) also emit a directive so the model treats it as a command.
474
525
  const mentioned = detectMentionedConnectors(args.userText, pluginCatalog);
475
- const narrowSet = mentioned.strong.size > 0 ? mentioned.strong
476
- : mentioned.medium.size > 0 ? mentioned.medium
477
- : null;
526
+ const autoOpenFromUserText: Set<string> = mentioned.strong.size > 0 ? mentioned.strong
527
+ : mentioned.medium.size > 0 ? mentioned.medium
528
+ : new Set();
478
529
  let narrowDirective = '';
479
- if (narrowSet) {
480
- connectorTools = connectorTools.filter((t) => narrowSet.has(t.name.split('.')[0]!));
481
- const list = [...narrowSet].join(', ');
482
- if (mentioned.strong.size > 0) {
483
- narrowDirective = `\n\nUSER MENTIONED CONNECTOR(S) EXPLICITLY (slash-prefix): ${list}. You MUST use these connector tools for this turn — do NOT answer without trying them first, and do NOT consider other connectors.`;
484
- console.log(`[chat] narrow STRONG → ${list}`);
485
- } else {
486
- console.log(`[chat] narrow MEDIUM → ${list}`);
487
- }
530
+ if (mentioned.strong.size > 0) {
531
+ const list = [...mentioned.strong].join(', ');
532
+ narrowDirective = `\n\nUSER MENTIONED CONNECTOR(S) EXPLICITLY (slash-prefix): ${list}. These are already open for this turn — use their tools directly, do NOT call connector_open for them.`;
533
+ console.log(`[chat] auto-open STRONG → ${list}`);
534
+ } else if (mentioned.medium.size > 0) {
535
+ console.log(`[chat] auto-open MEDIUM → ${[...mentioned.medium].join(', ')}`);
488
536
  }
489
537
 
490
538
  console.log(
491
- `[chat] tools=${connectorTools.length} ` +
492
- connectorTools.map((t) => t.name).join(', ').slice(0, 600),
539
+ `[chat] total connector tools=${allConnectorTools.length} across ${allConnectorIds.length} connectors`,
493
540
  );
494
541
 
495
542
  const builtinDefsAll = [
@@ -497,17 +544,70 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
497
544
  ...memTools.map((m) => m.def),
498
545
  watchTool.def,
499
546
  ];
500
- const allTools: LlmTool[] = [
501
- ...builtinDefsAll.map((t) => ({
502
- name: t.name,
503
- description: t.description,
504
- input_schema: t.input_schema,
505
- })),
506
- ...connectorTools,
507
- ];
547
+ const builtinToolDefs: LlmTool[] = builtinDefsAll.map((t) => ({
548
+ name: t.name,
549
+ description: t.description,
550
+ input_schema: t.input_schema,
551
+ }));
552
+
553
+ // ── Open set computation ─────────────────────────────────────────
554
+ // A connector is "open" for this user-task if the LLM has called
555
+ // connector_open(name=<id>) OR has called <id>.<tool> in any assistant
556
+ // turn since the most recent user TEXT message. User text messages mark
557
+ // task boundaries — on the next user message, the open set resets to
558
+ // just what user-text auto-opens seed.
559
+ //
560
+ // assistantBlocksAccum captures the in-progress turn's blocks (not yet
561
+ // in history during the iteration). We OR them with the history scan.
562
+ function computeOpenSet(history: Message[], currentBlocks: ContentBlock[]): Set<string> {
563
+ const ns = new Set<string>(autoOpenFromUserText);
564
+ // Find the most recent user message that has text content. Tool-result
565
+ // user messages don't count as task boundaries.
566
+ let lastUserIdx = -1;
567
+ for (let i = history.length - 1; i >= 0; i--) {
568
+ const m = history[i]!;
569
+ if (m.role !== 'user') continue;
570
+ if (m.blocks.some((b) => b.type === 'text')) { lastUserIdx = i; break; }
571
+ }
572
+ const start = lastUserIdx + 1;
573
+ for (let i = start; i < history.length; i++) {
574
+ const m = history[i]!;
575
+ if (m.role !== 'assistant') continue;
576
+ for (const b of m.blocks) {
577
+ if (b.type !== 'tool_use') continue;
578
+ if (b.name === 'connector_open') {
579
+ const opened = (b.input as { name?: string } | undefined)?.name;
580
+ if (opened) ns.add(String(opened));
581
+ } else if (b.name.includes('.')) {
582
+ ns.add(b.name.split('.')[0]!);
583
+ }
584
+ }
585
+ }
586
+ for (const b of currentBlocks) {
587
+ if (b.type !== 'tool_use') continue;
588
+ if (b.name === 'connector_open') {
589
+ const opened = (b.input as { name?: string } | undefined)?.name;
590
+ if (opened) ns.add(String(opened));
591
+ } else if (b.name.includes('.')) {
592
+ ns.add(b.name.split('.')[0]!);
593
+ }
594
+ }
595
+ return ns;
596
+ }
597
+
598
+ const sessionSystemPrompt = session.system_prompt;
599
+ function buildSystem(openTools: LlmTool[], openSet: Set<string>): string {
600
+ let s = buildSystemPrompt(openTools, openSet, builtinDefsAll, sessionSystemPrompt);
601
+ if (narrowDirective) s += narrowDirective;
602
+ return s;
603
+ }
508
604
 
509
- let system = buildSystemPrompt(connectorTools, builtinDefsAll, session.system_prompt);
510
- if (narrowDirective) system += narrowDirective;
605
+ // Initial open set (before any iteration): just user-text auto-open seeds
606
+ let openSet: Set<string> = new Set(autoOpenFromUserText);
607
+ let openConnectorTools = allConnectorTools.filter((t) => openSet.has(t.name.split('.')[0]!));
608
+ let allTools: LlmTool[] = [...builtinToolDefs, ...openConnectorTools];
609
+
610
+ let system = buildSystem(openConnectorTools, openSet);
511
611
  if (memContext) system += '\n\n─── Memory context (auto-loaded) ───\n' + memContext;
512
612
  if (memStore.enabled) {
513
613
  const searchHint = memStore.kind === 'local'
@@ -538,9 +638,37 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
538
638
  return { ok: false, error: 'empty history' };
539
639
  }
540
640
 
641
+ // ── Recompute open set every iteration ──────────────────────
642
+ // Scan history (since last user text msg) + this turn's accumulated
643
+ // blocks → which connectors are open right now. Then filter tools.
644
+ // First iteration: only user-text auto-opens seed the set. After
645
+ // the LLM calls connector_open, subsequent iterations pick that up.
646
+ const newOpenSet = computeOpenSet(history, assistantBlocksAccum);
647
+ const setChanged = newOpenSet.size !== openSet.size ||
648
+ [...newOpenSet].some((n) => !openSet.has(n));
649
+ if (setChanged) {
650
+ openSet = newOpenSet;
651
+ openConnectorTools = allConnectorTools.filter((t) => openSet.has(t.name.split('.')[0]!));
652
+ allTools = [...builtinToolDefs, ...openConnectorTools];
653
+ system = buildSystem(openConnectorTools, openSet);
654
+ if (memContext) system += '\n\n─── Memory context (auto-loaded) ───\n' + memContext;
655
+ console.log(`[chat] open set → {${[...openSet].join(',')}} (${openConnectorTools.length} connector tools active)`);
656
+ }
657
+
541
658
  assistantBlocksAccum = [];
542
659
  let currentTextBuf = '';
543
660
 
661
+ // ── Token composition log (input side, BEFORE the call) ──
662
+ // Heuristic char/4. Lets you correlate later with the provider's
663
+ // real usage.input_tokens — if the gap widens turn-over-turn, the
664
+ // memory/tools blob is silently growing.
665
+ const _systemTok = Math.ceil(system.length / 4);
666
+ const _memCtxTok = Math.ceil(memContext.length / 4);
667
+ const _toolsTok = Math.ceil(JSON.stringify(allTools).length / 4);
668
+ const _historyTok = history.reduce((s, m) => s + estimateTokens(m), 0);
669
+ const _historyMsgs = history.length;
670
+ console.log(`[chat-tokens] session=${args.sessionId} turn=${iter} est_in=${_systemTok + _historyTok + _toolsTok} system=${_systemTok} history=${_historyTok}(${_historyMsgs}msgs) memory=${_memCtxTok} tools=${_toolsTok}`);
671
+
544
672
  const result = await streamLlm(
545
673
  {
546
674
  provider: provider.type,
@@ -563,6 +691,12 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
563
691
  },
564
692
  );
565
693
 
694
+ // ── Real usage from the provider (when reported) ──
695
+ if (result.usage) {
696
+ const u = result.usage;
697
+ console.log(`[chat-tokens] session=${args.sessionId} turn=${iter} REAL in=${u.inputTokens ?? '?'} out=${u.outputTokens ?? '?'} cache_read=${u.cacheReadTokens ?? 0} cache_create=${u.cacheCreationTokens ?? 0} stop=${result.stopReason}`);
698
+ }
699
+
566
700
  lastStop = result.stopReason;
567
701
  assistantBlocksAccum = result.content;
568
702
 
@@ -31,6 +31,10 @@ export interface BuildMemoryContextOpts {
31
31
  /** Prefixes that mark internal-only blocks (cursor / health / etc).
32
32
  * Defaults to lib/memory/keys.INTERNAL_KEY_PREFIXES. */
33
33
  excludeKeyPrefixes?: readonly string[];
34
+ /** Current chat session id. When set, blocks keyed `chat:<otherId>:*`
35
+ * are dropped — other sessions' summaries are noise in this chat and
36
+ * were the primary source of "old data bleeding into new chat". */
37
+ currentSessionId?: string;
34
38
  }
35
39
 
36
40
  export interface BuildMemoryContextResult {
@@ -46,18 +50,22 @@ export async function buildMemoryContext(opts: BuildMemoryContextOpts): Promise<
46
50
  topK = 6,
47
51
  maxBlocks = 50,
48
52
  excludeKeyPrefixes = INTERNAL_KEY_PREFIXES,
53
+ currentSessionId,
49
54
  } = opts;
50
55
 
51
- const blocks = filterInternal(
52
- await safe(() => store.listBlocks({ pinned: true }), [] as MemoryBlock[]),
53
- excludeKeyPrefixes,
56
+ const blocks = dropForeignChat(
57
+ filterInternal(
58
+ await safe(() => store.listBlocks({ pinned: true }), [] as MemoryBlock[]),
59
+ excludeKeyPrefixes,
60
+ ),
61
+ currentSessionId,
54
62
  ).slice(0, maxBlocks);
55
63
 
56
64
  const q = (currentUserMessage || '').trim();
57
65
  let hits: SearchHit[] = [];
58
66
  if (q) {
59
67
  const rawHits = await safe(() => store.search(q, topK), [] as SearchHit[]);
60
- hits = filterInternalHits(rawHits, excludeKeyPrefixes);
68
+ hits = dropForeignChatHits(filterInternalHits(rawHits, excludeKeyPrefixes), currentSessionId);
61
69
  }
62
70
 
63
71
  return { text: renderMemoryContext(blocks, hits), blocks, hits };
@@ -81,6 +89,30 @@ function filterInternalHits(hits: SearchHit[], prefixes: readonly string[]): Sea
81
89
  });
82
90
  }
83
91
 
92
+ /** Strip `chat:<otherSessionId>:*` blocks. Summary blocks contain raw
93
+ * past-conversation excerpts; surfacing them in a different chat is
94
+ * what made "new empty chat" leak old session content. Facts
95
+ * (`fact:*`) and any non-chat-prefixed pinned blocks stay — they're
96
+ * the intentional cross-session signal. No-op if no sessionId given. */
97
+ function dropForeignChat(blocks: MemoryBlock[], sessionId?: string): MemoryBlock[] {
98
+ if (!sessionId) return blocks;
99
+ return blocks.filter((b) => isOwnChatOrNotChat(b.key, sessionId));
100
+ }
101
+
102
+ function dropForeignChatHits(hits: SearchHit[], sessionId?: string): SearchHit[] {
103
+ if (!sessionId) return hits;
104
+ return hits.filter((h) => {
105
+ if (!h.id?.startsWith('block:')) return true; // Graphiti hit, no key to inspect — keep
106
+ return isOwnChatOrNotChat(h.id.slice('block:'.length), sessionId);
107
+ });
108
+ }
109
+
110
+ function isOwnChatOrNotChat(key: string, sessionId: string): boolean {
111
+ if (!key.startsWith('chat:')) return true;
112
+ // key shape: chat:<sessionId>:summary:<ts> → split[1] === sessionId
113
+ return key.split(':', 2)[1] === sessionId;
114
+ }
115
+
84
116
  async function safe<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
85
117
  try {
86
118
  return await fn();
@@ -132,14 +132,30 @@ export const anthropicAdapter: LlmAdapter = {
132
132
  // execute — chat owns dispatch (destructive confirm, browser bridge,
133
133
  // memory tools etc all live in agent-loop). Setting stopWhen with
134
134
  // stepCountIs(1) prevents the SDK from auto-rolling a second step.
135
+ // Build tool record. Mark the LAST tool with cache_control so
136
+ // Anthropic-family backends (or LiteLLM proxies that forward it)
137
+ // cache the system+tools prefix. Subsequent turns within the 5-min
138
+ // TTL pay 0.1× input price for the cached portion instead of 1×.
139
+ // Backends that don't honor cache_control silently ignore it,
140
+ // costing nothing.
141
+ const toolNames = req.tools.map((t) => t.name);
142
+ const lastName = toolNames[toolNames.length - 1];
135
143
  const tools: Record<string, any> = {};
136
144
  for (const t of req.tools) {
137
145
  tools[encodeToolName(t.name)] = {
138
146
  description: t.description,
139
147
  inputSchema: jsonSchema(t.input_schema),
148
+ ...(t.name === lastName ? {
149
+ providerOptions: {
150
+ anthropic: { cacheControl: { type: 'ephemeral' } },
151
+ },
152
+ } : {}),
140
153
  };
141
154
  }
142
155
 
156
+ // Single cache breakpoint at end-of-tools — Anthropic caches the
157
+ // prefix (system + tools) since system comes first in the wire
158
+ // format. No need to add a separate marker on system.
143
159
  const result = streamText({
144
160
  model: client(req.model),
145
161
  system: req.system,
@@ -169,6 +185,19 @@ export const anthropicAdapter: LlmAdapter = {
169
185
  if (textBuf.length > 0) content.push({ type: 'text', text: textBuf });
170
186
 
171
187
  const finishReason = await result.finishReason;
172
- return { stopReason: mapStop(finishReason), content };
188
+ let usage;
189
+ try {
190
+ const u: any = await result.usage;
191
+ if (u) {
192
+ usage = {
193
+ inputTokens: u.inputTokens ?? u.promptTokens,
194
+ outputTokens: u.outputTokens ?? u.completionTokens,
195
+ cacheReadTokens: u.cachedInputTokens ?? u.cacheReadInputTokens,
196
+ cacheCreationTokens: u.cacheCreationInputTokens,
197
+ totalTokens: u.totalTokens,
198
+ };
199
+ }
200
+ } catch {}
201
+ return { stopReason: mapStop(finishReason), content, usage };
173
202
  },
174
203
  };
@@ -108,6 +108,17 @@ export const openaiAdapter: LlmAdapter = {
108
108
  if (textBuf.length > 0) content.push({ type: 'text', text: textBuf });
109
109
 
110
110
  const finishReason = await result.finishReason;
111
- return { stopReason: mapStop(finishReason), content };
111
+ let usage;
112
+ try {
113
+ const u: any = await result.usage;
114
+ if (u) {
115
+ usage = {
116
+ inputTokens: u.inputTokens ?? u.promptTokens,
117
+ outputTokens: u.outputTokens ?? u.completionTokens,
118
+ totalTokens: u.totalTokens,
119
+ };
120
+ }
121
+ } catch {}
122
+ return { stopReason: mapStop(finishReason), content, usage };
112
123
  },
113
124
  };
@@ -21,9 +21,20 @@ export interface LlmCallbacks {
21
21
 
22
22
  export type StopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'refusal' | 'error' | 'other';
23
23
 
24
+ export interface LlmTurnUsage {
25
+ inputTokens?: number;
26
+ outputTokens?: number;
27
+ cacheReadTokens?: number;
28
+ cacheCreationTokens?: number;
29
+ totalTokens?: number;
30
+ }
31
+
24
32
  export interface LlmTurnResult {
25
33
  stopReason: StopReason;
26
34
  content: ContentBlock[];
35
+ /** Token usage from the provider, if reported. May be partially-filled
36
+ * or absent for proxies that don't expose it. */
37
+ usage?: LlmTurnUsage;
27
38
  }
28
39
 
29
40
  export interface LlmRequest {
@@ -327,16 +327,67 @@ export function listMessagesCapped(
327
327
  // loop (provider will see a single message — still valid).
328
328
  const keptGroups: Message[][] = [];
329
329
  let used = 0;
330
+ let evictedCount = 0;
330
331
  for (let i = groups.length - 1; i >= 0; i--) {
331
332
  const g = groups[i];
332
333
  const cost = g.reduce((s, m) => s + estimateTokens(m), 0);
333
- if (keptGroups.length > 0 && used + cost > tokenBudget) break;
334
+ if (keptGroups.length > 0 && used + cost > tokenBudget) {
335
+ evictedCount = i + 1; // groups [0..i] would have been evicted
336
+ break;
337
+ }
334
338
  keptGroups.unshift(g);
335
339
  used += cost;
336
340
  }
341
+
342
+ // ── Pin the SESSION's first user message (task brief) ──────────
343
+ // Even if eviction would normally drop it, the user's opening prompt
344
+ // defines the task. Losing it causes the model to lose track of
345
+ // what was asked — symptom: model writes "summarize all X" and
346
+ // hallucinates instead of processing the specific list the user
347
+ // gave. Re-fetch the absolute first user message, prepend if not
348
+ // already in keptGroups. Cap its tokens so a truly enormous brief
349
+ // can't break the call — keep first ~2k tokens.
350
+ if (evictedCount > 0) {
351
+ const firstUserRow = db().prepare(`
352
+ SELECT * FROM chat_messages WHERE session_id = ? AND role = 'user'
353
+ ORDER BY ts ASC LIMIT 1
354
+ `).get(session_id) as MessageRow | undefined;
355
+ if (firstUserRow) {
356
+ const firstUserMsg = rowToMessage(firstUserRow);
357
+ const alreadyKept = keptGroups.some((g) => g.some((m) => m.id === firstUserMsg.id));
358
+ if (!alreadyKept) {
359
+ // Cap to ~2000 tokens of brief (≈8KB) — tasks longer than that
360
+ // should be split anyway; preserving the head is enough to
361
+ // anchor the model to the original ask.
362
+ const FIRST_BRIEF_TOKEN_CAP = 2000;
363
+ let pinned = firstUserMsg;
364
+ if (estimateTokens(firstUserMsg) > FIRST_BRIEF_TOKEN_CAP) {
365
+ pinned = clipMessageToTokens(firstUserMsg, FIRST_BRIEF_TOKEN_CAP);
366
+ }
367
+ keptGroups.unshift([pinned]);
368
+ console.log(`[session-cap] pinned first user message (id=${firstUserMsg.id}) — ${evictedCount} groups evicted, ${used} tokens used / ${tokenBudget} budget`);
369
+ }
370
+ } else {
371
+ console.log(`[session-cap] ${evictedCount} groups evicted, no first user message found to pin`);
372
+ }
373
+ }
337
374
  return keptGroups.flat();
338
375
  }
339
376
 
377
+ /** Clip a message's text content to a soft token cap. Tool blocks are
378
+ * preserved verbatim (they're usually small structural data); only
379
+ * long text blocks get a head-only truncation with a marker. */
380
+ function clipMessageToTokens(m: Message, tokenCap: number): Message {
381
+ const charCap = tokenCap * 4; // matches estimateTokens char/4 heuristic
382
+ const blocks = m.blocks.map((b) => {
383
+ if (b.type === 'text' && b.text.length > charCap) {
384
+ return { ...b, text: b.text.slice(0, charCap) + '\n\n[…task brief truncated to keep in-context]' };
385
+ }
386
+ return b;
387
+ });
388
+ return { ...m, blocks };
389
+ }
390
+
340
391
  export function deleteMessage(id: string): boolean {
341
392
  ensureSchema();
342
393
  const r = db().prepare(`DELETE FROM chat_messages WHERE id = ?`).run(id);
@@ -336,6 +336,31 @@ const BUILTINS: Record<string, BuiltinHandler> = {
336
336
  ? content.slice(0, MAX) + `\n\n…[truncated — doc is ${content.length} chars]`
337
337
  : content;
338
338
  },
339
+
340
+ // Namespace gating meta-tool. Connector tools (mantis.*, gitlab.*, etc.)
341
+ // are NOT in the active tools list by default — only their catalog entry
342
+ // is visible in the system prompt. Calling connector_open({name}) makes
343
+ // that connector's tools available for the rest of this user-task (until
344
+ // the next user message). The handler returns a tiny confirmation; the
345
+ // actual side-effect is that agent-loop scans assistant history for
346
+ // these calls (and direct namespaced calls) to compute the "open set"
347
+ // each turn.
348
+ connector_open: async (input) => {
349
+ const { name } = (input as { name?: string } | undefined) || {};
350
+ if (!name) return JSON.stringify({ ok: false, error: 'name is required (e.g. "mantis", "gitlab", "nac")' });
351
+ const def = getConnector(String(name));
352
+ if (!def) return JSON.stringify({ ok: false, error: `unknown connector: ${name}. Call connector_open with a name from the catalog in the system prompt.` });
353
+ let toolCount = 0;
354
+ for (const entry of getConnectorEntries(def)) {
355
+ toolCount += Object.keys(entry.tools || {}).length;
356
+ }
357
+ return JSON.stringify({
358
+ ok: true,
359
+ connector: name,
360
+ tool_count: toolCount,
361
+ message: `${name} loaded — ${toolCount} tools available next turn. Call them as ${name}.<tool_name>.`,
362
+ });
363
+ },
339
364
  };
340
365
 
341
366
  export interface BuiltinToolDef {
@@ -446,6 +471,20 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
446
471
  required: ['doc'],
447
472
  },
448
473
  },
474
+ {
475
+ name: 'connector_open',
476
+ description: 'Load a connector to make its tools (e.g. mantis.search_bugs, gitlab.list_my_todos) available for use. REQUIRED before calling any connector tool — the catalog block in the system prompt shows what each connector can do. Tools stay loaded only for the current user task; the next user message resets the open set, so re-open as needed.',
477
+ input_schema: {
478
+ type: 'object',
479
+ properties: {
480
+ name: {
481
+ type: 'string',
482
+ description: 'Connector id from the catalog (e.g. "mantis", "gitlab", "nac", "tp", "jenkins", "teams", "pmdb", "github-api").',
483
+ },
484
+ },
485
+ required: ['name'],
486
+ },
487
+ },
449
488
  ];
450
489
 
451
490
  // ─── Connector dispatch ──────────────────────────────────
@@ -477,6 +477,14 @@ export interface ConnectorDefinition {
477
477
  author?: string;
478
478
  description?: string;
479
479
 
480
+ /**
481
+ * 2-4 line English summary of what this connector can do, shown in the
482
+ * chat system prompt's "Connector catalog" block. Drives the LLM's
483
+ * decision to call connector_open(<id>). When absent, the agent falls
484
+ * back to listing the first few tool names — informative but flat.
485
+ */
486
+ catalog_summary?: string;
487
+
480
488
  /**
481
489
  * Minimum Forge version this manifest expects. The registry filter
482
490
  * hides newer-than-supported manifests so users on older Forge
@@ -40,6 +40,45 @@ function parseResult(content: string): any {
40
40
  try { return JSON.parse(content); } catch { return { _raw: content }; }
41
41
  }
42
42
 
43
+ /** Heuristic: spot common "this work is finished" shapes from a poll
44
+ * result, regardless of whether the connector author thought to set
45
+ * `terminal: true` or pre-declare done conditions. Walks well-known
46
+ * state-bearing fields (state / status / phase / result / done /
47
+ * finished / complete / completed) and matches their values against
48
+ * a curated vocabulary used across CI, Jenkins, k8s, generic build
49
+ * systems, etc.
50
+ * Returns { failure } when a hit is found, null otherwise. Intended
51
+ * to run AFTER user's explicit done_match/done_path, so a caller who
52
+ * configured "done when status == running" (rare but legal) still
53
+ * wins. */
54
+ function detectTerminalState(obj: any): { failure: boolean; source: string; value: string } | null {
55
+ if (!obj || typeof obj !== 'object') return null;
56
+ // Boolean done-ish flags
57
+ for (const f of ['done', 'finished', 'complete', 'completed']) {
58
+ if (truthy(obj[f])) return { failure: false, source: f, value: 'true' };
59
+ }
60
+ // State-bearing fields with a terminal vocabulary
61
+ const fields = ['state', 'status', 'phase', 'result', 'conclusion', 'lifecycle_state'];
62
+ const failureWords = new Set([
63
+ 'failed', 'failure', 'error', 'errored', 'cancelled', 'canceled',
64
+ 'aborted', 'killed', 'terminated', 'timeout', 'timed_out', 'rejected',
65
+ 'unstable', 'broken',
66
+ ]);
67
+ const successWords = new Set([
68
+ 'done', 'success', 'succeeded', 'complete', 'completed', 'finished',
69
+ 'passed', 'ok', 'green', 'healthy',
70
+ ]);
71
+ for (const f of fields) {
72
+ const raw = obj[f];
73
+ if (raw == null) continue;
74
+ const v = String(raw).toLowerCase().trim();
75
+ if (!v) continue;
76
+ if (failureWords.has(v)) return { failure: true, source: f, value: v };
77
+ if (successWords.has(v)) return { failure: false, source: f, value: v };
78
+ }
79
+ return null;
80
+ }
81
+
43
82
  const g = globalThis as any;
44
83
 
45
84
  export function startWatchRunner(hooks: WatchRunnerHooks = {}): void {
@@ -120,7 +159,27 @@ export function startWatchRunner(hooks: WatchRunnerHooks = {}): void {
120
159
  if (w.fail_path && truthy(getPath(obj, w.fail_path))) {
121
160
  return finish(w, 'failed', obj, `${w.label}: failure condition met.`);
122
161
  }
123
- // done check
162
+ // Hard terminal check — if the poll tool itself says "this is a
163
+ // terminal state" (cancelled / failed / done / etc.), believe it
164
+ // regardless of the user-configured done condition. Without this,
165
+ // a watch on get_pipeline_status with done_match={status:"done"}
166
+ // would keep polling after the user cancels the pipeline, because
167
+ // status="cancelled" never matches "done" — wasting polls until
168
+ // max_polls / timeout. The builtin status tools (get_pipeline_status,
169
+ // get_task_status) all set obj.terminal = true on cancelled/failed
170
+ // too, so honoring it here drops the watch the moment the user
171
+ // intervenes.
172
+ if (truthy(getPath(obj, 'terminal'))) {
173
+ const statusVal = String(getPath(obj, 'status') || '').toLowerCase();
174
+ const isFailureLike = statusVal === 'failed' || statusVal === 'cancelled';
175
+ return finish(
176
+ w,
177
+ isFailureLike ? 'failed' : 'done',
178
+ obj,
179
+ `${w.label}: ${statusVal || 'reached a terminal state'}.`,
180
+ );
181
+ }
182
+ // done check (user-configured)
124
183
  let done = false;
125
184
  if (w.done_match) {
126
185
  const v = getPath(obj, w.done_match.path);
@@ -132,6 +191,22 @@ export function startWatchRunner(hooks: WatchRunnerHooks = {}): void {
132
191
  if (done) {
133
192
  return finish(w, 'done', obj, `${w.label}: done.`);
134
193
  }
194
+ // Heuristic terminal detection — fallback for connector pollers
195
+ // that don't set obj.terminal AND whose authors didn't anticipate
196
+ // a particular done condition. If the poll result has a common
197
+ // "I'm finished" shape (state/status/phase/result with a known
198
+ // terminal word, or done:true / finished:true), trust it. User's
199
+ // explicit done_match/done_path runs first (above), so a watch
200
+ // wanting "done when status==running" still works as intended.
201
+ const term = detectTerminalState(obj);
202
+ if (term) {
203
+ return finish(
204
+ w,
205
+ term.failure ? 'failed' : 'done',
206
+ obj,
207
+ `${w.label}: detected ${term.source}=${term.value} — closing watch.`,
208
+ );
209
+ }
135
210
  // not done — bound by polls / timeout, else reschedule
136
211
  if (polls >= w.max_polls || now - w.created_at > w.timeout_sec * 1000) {
137
212
  return finish(w, 'timed_out', obj, `${w.label}: not done within ${w.max_polls} polls / ${w.timeout_sec}s — please verify manually.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.33",
3
+ "version": "0.10.35",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {