@aion0/forge 0.10.75 → 0.10.77

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,11 +1,12 @@
1
- # Forge v0.10.75
1
+ # Forge v0.10.77
2
2
 
3
- Released: 2026-06-11
3
+ Released: 2026-06-12
4
4
 
5
- ## Changes since v0.10.74
5
+ ## Changes since v0.10.76
6
6
 
7
7
  ### Other
8
- - fix(pipeline): orphan reaper only kills tasks older than this boot
8
+ - fix(ui): route idpManualUrls open through openPortal too
9
+ - feat(ui): Open-portal buttons route through container Chromium when present
9
10
 
10
11
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.74...v0.10.75
12
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.76...v0.10.77
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
20
+ import { openPortal } from '@/lib/ui/openPortal';
20
21
 
21
22
  interface MarketEntry {
22
23
  id: string;
@@ -929,9 +930,8 @@ function TemplateImportModal({
929
930
  {p.url && (
930
931
  <a
931
932
  href={p.url}
932
- target="_blank"
933
- rel="noopener noreferrer"
934
- className="ml-auto text-[10px] text-[var(--accent)] hover:underline"
933
+ onClick={(e) => { e.preventDefault(); void openPortal(p.url!); }}
934
+ className="ml-auto text-[10px] text-[var(--accent)] hover:underline cursor-pointer"
935
935
  title={p.url}
936
936
  >
937
937
  ↗ {p.url_label || 'Get token'}
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { useEffect, useRef, useState } from 'react';
19
+ import { openPortal } from '@/lib/ui/openPortal';
19
20
 
20
21
  interface SourceView {
21
22
  tenant_id: string;
@@ -171,7 +172,7 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
171
172
  r.source.refresh.url,
172
173
  );
173
174
  failed.forEach((r, i) => setTimeout(() => {
174
- window.open(r.source.refresh.url!, '_blank', 'noopener');
175
+ void openPortal(r.source.refresh.url!);
175
176
  }, i * 250));
176
177
  }
177
178
  } catch (e) {
@@ -183,7 +184,7 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
183
184
 
184
185
  const handleOpenWeb = (row: { refresh: { kind: string; url?: string } }) => {
185
186
  if (row.refresh.kind === 'open-url' && row.refresh.url) {
186
- window.open(row.refresh.url, '_blank', 'noopener');
187
+ void openPortal(row.refresh.url);
187
188
  }
188
189
  };
189
190
 
@@ -533,10 +534,9 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
533
534
  <div key={i} className="flex items-center gap-1">
534
535
  <a
535
536
  href={u.url}
536
- target="_blank"
537
- rel="noopener"
537
+ onClick={(e) => { e.preventDefault(); void openPortal(u.url); }}
538
538
  title={u.error || u.host}
539
- className="flex-1 bg-black/30 px-1.5 py-0.5 rounded font-mono text-[10px] break-all hover:bg-black/40 underline"
539
+ className="flex-1 bg-black/30 px-1.5 py-0.5 rounded font-mono text-[10px] break-all hover:bg-black/40 underline cursor-pointer"
540
540
  >
541
541
  {u.url}
542
542
  </a>
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useCallback } from 'react';
4
+ import { openPortal } from '@/lib/ui/openPortal';
4
5
 
5
6
  type LoginCategory = 'browser' | 'token' | 'external';
6
7
 
@@ -104,7 +105,7 @@ export default function LoginStatusPanel({ onClose }: { onClose: () => void }) {
104
105
  const refresh = (source: LoginSource) => {
105
106
  const r = source.refresh;
106
107
  if (r.kind === 'open-url') {
107
- window.open(r.url, '_blank', 'noopener');
108
+ void openPortal(r.url);
108
109
  } else if (r.kind === 'show-command') {
109
110
  setShowCmd({ command: r.command, description: r.description });
110
111
  } else if (r.kind === 'open-settings') {
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { useEffect, useRef, useState } from 'react';
17
+ import { openPortal } from '@/lib/ui/openPortal';
17
18
 
18
19
  // ─── Types echoing the API surface ───────────────────────────
19
20
 
@@ -746,9 +747,8 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
746
747
  {!ok && ref?.kind === 'open-url' && ref.url && (
747
748
  <a
748
749
  href={ref.url}
749
- target="_blank"
750
- rel="noopener noreferrer"
751
- className="text-[var(--accent)] hover:underline ml-auto"
750
+ onClick={(e) => { e.preventDefault(); void openPortal(ref.url!); }}
751
+ className="text-[var(--accent)] hover:underline ml-auto cursor-pointer"
752
752
  title={ref.description || ref.url}
753
753
  >
754
754
  ↗ login
@@ -1242,7 +1242,7 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
1242
1242
  {!p.required && !isSet && <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
1243
1243
  {isSet && <span className="text-[9px] text-emerald-500">● currently set</span>}
1244
1244
  {p.url && (
1245
- <a href={p.url} target="_blank" rel="noopener noreferrer" className="ml-auto text-[10px] text-[var(--accent)] hover:underline" title={p.url}>
1245
+ <a href={p.url} onClick={(e) => { e.preventDefault(); void openPortal(p.url!); }} className="ml-auto text-[10px] text-[var(--accent)] hover:underline cursor-pointer" title={p.url}>
1246
1246
  ↗ {p.url_label || 'Get token'}
1247
1247
  </a>
1248
1248
  )}
@@ -1054,6 +1054,30 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
1054
1054
  // returns at session-not-found and provider-error, and any throw
1055
1055
  // anywhere in the function. Guarantees the turn-control running
1056
1056
  // flag is always cleared so future inputs can start fresh turns.
1057
+ //
1058
+ // Leftover notes: an input can land between the loop's FINAL
1059
+ // consumeNotes and here — enqueueChatInput saw running=true and
1060
+ // merged it, but no iteration will ever consume it. endTurn wipes
1061
+ // notes, so without this drain the message is silently lost and
1062
+ // the sender's UI hangs on "pending" forever. Take them first and
1063
+ // replay through the queue so they start a follow-up turn (or
1064
+ // merge into whichever turn claimed in the meantime).
1065
+ const leftovers = consumeNotes(args.sessionId);
1057
1066
  endTurn(args.sessionId);
1067
+ if (leftovers.length > 0) {
1068
+ // Lazy require: input-queue imports runTurn from this module —
1069
+ // a static import here would be circular.
1070
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1071
+ const { enqueueChatInput } = require('./input-queue') as typeof import('./input-queue');
1072
+ for (const text of leftovers) {
1073
+ enqueueChatInput({
1074
+ sessionId: args.sessionId,
1075
+ text,
1076
+ mode: 'turn',
1077
+ source: 'user',
1078
+ onEvent: args.callbacks?.onEvent,
1079
+ });
1080
+ }
1081
+ }
1058
1082
  }
1059
1083
  }
@@ -108,6 +108,11 @@ function dropForeignChatHits(hits: SearchHit[], sessionId?: string): SearchHit[]
108
108
  }
109
109
 
110
110
  function isOwnChatOrNotChat(key: string, sessionId: string): boolean {
111
+ // Local-backend chat-summary documents: doc:chats/<sessionId>.md —
112
+ // same cross-session noise concern as the legacy chat:* blocks.
113
+ if (key.startsWith('doc:chats/')) {
114
+ return key.slice('doc:chats/'.length).replace(/\.md$/, '') === sessionId;
115
+ }
111
116
  if (!key.startsWith('chat:')) return true;
112
117
  // key shape: chat:<sessionId>:summary:<ts> → split[1] === sessionId
113
118
  return key.split(':', 2)[1] === sessionId;
@@ -21,7 +21,10 @@ import { getDb } from '../../src/core/db/database';
21
21
  import { getDataDir } from '../dirs';
22
22
  import type Database from 'better-sqlite3';
23
23
  import type {
24
+ BulkEpisodeResult,
25
+ DocumentInput,
24
26
  EpisodeInput,
27
+ EpisodeWriteResult,
25
28
  MemoryBlock,
26
29
  MemoryStore,
27
30
  SearchHit,
@@ -198,11 +201,11 @@ export class LocalMemoryStore implements MemoryStore {
198
201
  return hits.slice(0, cap);
199
202
  }
200
203
 
201
- async writeEpisode(ep: EpisodeInput, _asyncExtract = true): Promise<boolean> {
202
- if (!ep.content || !ep.content.trim()) return false;
204
+ async writeEpisode(ep: EpisodeInput, _asyncExtract = true): Promise<EpisodeWriteResult> {
205
+ if (!ep.content || !ep.content.trim()) return { ok: false, skipped: false };
203
206
  try {
204
207
  const conn = db();
205
- conn.prepare(
208
+ const r = conn.prepare(
206
209
  `INSERT INTO memory_episodes(ns, content, tags, source_type, reference_time, created_at)
207
210
  VALUES (?, ?, ?, ?, ?, ?)`,
208
211
  ).run(
@@ -213,11 +216,39 @@ export class LocalMemoryStore implements MemoryStore {
213
216
  ep.reference_time ?? nowIso(),
214
217
  nowIso(),
215
218
  );
216
- return true;
219
+ return { ok: true, skipped: false, episode_id: String(r.lastInsertRowid) };
217
220
  } catch (err) {
218
221
  console.warn('[local-memory] writeEpisode failed', err);
219
- return false;
222
+ return { ok: false, skipped: false };
223
+ }
224
+ }
225
+
226
+ /** Local has no bulk endpoint — loop. Same per-item semantics. */
227
+ async writeEpisodesBulk(items: EpisodeInput[], _opts: { saga?: string } = {}): Promise<BulkEpisodeResult> {
228
+ const ids: string[] = [];
229
+ let skipped = 0;
230
+ for (const ep of items.slice(0, 200)) {
231
+ const r = await this.writeEpisode(ep);
232
+ if (r.ok && r.episode_id) ids.push(r.episode_id);
233
+ else skipped += 1;
220
234
  }
235
+ return { ok: true, episode_ids: ids, skipped_count: skipped };
236
+ }
237
+
238
+ /** Documents fall back to a `doc:<path>` block — keeps summaries
239
+ * addressable + searchable without a separate table. */
240
+ async putDocument(path: string, doc: DocumentInput): Promise<boolean> {
241
+ return this.putBlock(`doc:${path}`, { title: doc.title, content: doc.content, tags: doc.tags }, {
242
+ description: doc.title.slice(0, 120),
243
+ });
244
+ }
245
+
246
+ async getDocument(path: string): Promise<{ title: string; content: string } | null> {
247
+ const b = await this.getBlock(`doc:${path}`);
248
+ if (!b || typeof b.value !== 'object' || b.value === null) return null;
249
+ const v = b.value as { title?: string; content?: string };
250
+ if (typeof v.content !== 'string') return null;
251
+ return { title: v.title || path, content: v.content };
221
252
  }
222
253
 
223
254
  async listBlocks(opts: { pinned?: boolean; scope?: 'own' | 'global' | 'both' } = {}): Promise<MemoryBlock[]> {
@@ -18,9 +18,9 @@
18
18
  import { loadSettings } from '../settings';
19
19
  import { TemperClient } from './temper';
20
20
  import { LocalMemoryStore } from './local-memory';
21
- import type { EpisodeInput, MemoryBlock, SearchHit } from './temper';
21
+ import type { BulkEpisodeResult, DocumentInput, EpisodeInput, EpisodeWriteResult, MemoryBlock, SearchHit } from './temper';
22
22
 
23
- export type { EpisodeInput, MemoryBlock, SearchHit } from './temper';
23
+ export type { BulkEpisodeResult, DocumentInput, EpisodeInput, EpisodeWriteResult, MemoryBlock, SearchHit } from './temper';
24
24
 
25
25
  export interface MemoryStore {
26
26
  readonly enabled: boolean;
@@ -28,7 +28,9 @@ export interface MemoryStore {
28
28
  readonly kind: 'temper' | 'local';
29
29
 
30
30
  search(query: string, limit?: number): Promise<SearchHit[]>;
31
- writeEpisode(ep: EpisodeInput, asyncExtract?: boolean): Promise<boolean>;
31
+ writeEpisode(ep: EpisodeInput, asyncExtract?: boolean): Promise<EpisodeWriteResult>;
32
+ /** One round trip for many episodes (≤200). Local backend loops. */
33
+ writeEpisodesBulk(items: EpisodeInput[], opts?: { saga?: string }): Promise<BulkEpisodeResult>;
32
34
  listBlocks(opts?: { pinned?: boolean; scope?: 'own' | 'global' | 'both' }): Promise<MemoryBlock[]>;
33
35
  getBlock(key: string, scope?: 'own' | 'global'): Promise<MemoryBlock | null>;
34
36
  putBlock(
@@ -36,6 +38,11 @@ export interface MemoryStore {
36
38
  value: unknown,
37
39
  extras?: { pinned?: boolean; priority?: number; description?: string; scope?: 'own' | 'global' },
38
40
  ): Promise<boolean>;
41
+ /** Long-form addressable content (chat summaries, reports). Temper:
42
+ * /v1/documents upsert with revisions. Local: stored as a
43
+ * `doc:<path>` block so it stays searchable. */
44
+ putDocument(path: string, doc: DocumentInput): Promise<boolean>;
45
+ getDocument(path: string): Promise<{ title: string; content: string } | null>;
39
46
  ping(): Promise<{ ok: boolean; message: string; pinned: number }>;
40
47
  }
41
48
 
@@ -79,9 +86,12 @@ function makeTemperWrapper(c: TemperClient): MemoryStore {
79
86
  },
80
87
  search: (q, n) => c.search(q, n),
81
88
  writeEpisode: (ep, a) => c.writeEpisode(ep, a),
89
+ writeEpisodesBulk: (items, opts) => c.writeEpisodesBulk(items, opts),
82
90
  listBlocks: (opts) => c.listBlocks(opts ?? {}),
83
91
  getBlock: (k, scope) => c.getBlock(k, scope),
84
92
  putBlock: (k, v, extras) => c.putBlock(k, v, extras),
93
+ putDocument: (p, d) => c.putDocument(p, d),
94
+ getDocument: (p) => c.getDocument(p),
85
95
  ping: () => c.ping(),
86
96
  };
87
97
  }
@@ -149,8 +149,12 @@ export function buildMemoryTools(store: MemoryStore): MemoryTool[] {
149
149
  },
150
150
  handle: async (input) => {
151
151
  const { content, tags } = input as { content: string; tags?: string[] };
152
- const ok = await store.writeEpisode({ content, tags, source_type: 'text' });
153
- return ok ? 'event recorded' : 'event write failed';
152
+ const r = await store.writeEpisode({ content, tags, source_type: 'text' });
153
+ if (!r.ok) return 'event write failed';
154
+ // skipped = server rejected as duplicate/low-quality — tell the
155
+ // LLM so it doesn't retry the same content (api-reference §2).
156
+ if (r.skipped) return `event not stored (${r.skip_reason || 'duplicate or below quality floor'}) — do not retry the same content`;
157
+ return 'event recorded';
154
158
  },
155
159
  },
156
160
  ];
@@ -69,6 +69,33 @@ export interface EpisodeInput {
69
69
  namespace?: string;
70
70
  }
71
71
 
72
+ /** Outcome of an episode write. Per Temper's API contract (docs/
73
+ * api-reference.md §2): `skipped:true` arrives with HTTP 200 and means
74
+ * the server REJECTED the content (quality floor or dedup window) —
75
+ * treat as "don't retry", never as a transient failure. */
76
+ export interface EpisodeWriteResult {
77
+ ok: boolean;
78
+ skipped: boolean;
79
+ skip_reason?: string;
80
+ episode_id?: string;
81
+ }
82
+
83
+ export interface BulkEpisodeResult {
84
+ ok: boolean;
85
+ episode_ids: string[];
86
+ skipped_count: number;
87
+ }
88
+
89
+ export interface DocumentInput {
90
+ title: string;
91
+ content: string;
92
+ content_type?: string;
93
+ source?: string;
94
+ source_url?: string;
95
+ tags?: string[];
96
+ frontmatter?: Record<string, unknown>;
97
+ }
98
+
72
99
  export class TemperClient {
73
100
  constructor(
74
101
  private readonly baseUrl: string,
@@ -98,6 +125,19 @@ export class TemperClient {
98
125
  return u;
99
126
  }
100
127
 
128
+ /** fetch with backoff on 423 (namespace sleeping — consolidation in
129
+ * progress, per api-reference.md §12). Only used on WRITE paths;
130
+ * per-turn reads fail fast instead so a sleeping namespace never
131
+ * stalls a chat turn. */
132
+ private async fetchRetry423(url: string, init: RequestInit, attempts = 3): Promise<Response> {
133
+ let r = await fetch(url, init);
134
+ for (let i = 1; i < attempts && r.status === 423; i++) {
135
+ await new Promise((res) => setTimeout(res, 2000 * i));
136
+ r = await fetch(url, init);
137
+ }
138
+ return r;
139
+ }
140
+
101
141
  async search(query: string, limit = 8): Promise<SearchHit[]> {
102
142
  if (!this.enabled) return [];
103
143
  try {
@@ -121,8 +161,8 @@ export class TemperClient {
121
161
  }
122
162
  }
123
163
 
124
- async writeEpisode(ep: EpisodeInput, asyncExtract = true): Promise<boolean> {
125
- if (!this.enabled) return false;
164
+ async writeEpisode(ep: EpisodeInput, asyncExtract = true): Promise<EpisodeWriteResult> {
165
+ if (!this.enabled) return { ok: false, skipped: false };
126
166
  try {
127
167
  const u = new URL(this.url('/v1/episodes'));
128
168
  if (asyncExtract) u.searchParams.set('async_extract', 'true');
@@ -132,18 +172,89 @@ export class TemperClient {
132
172
  ...(this.namespace && !ep.namespace ? { namespace: this.namespace } : {}),
133
173
  ...ep,
134
174
  };
135
- const r = await fetch(u.toString(), {
175
+ const r = await this.fetchRetry423(u.toString(), {
136
176
  method: 'POST',
137
177
  headers: this.headers({ 'content-type': 'application/json' }),
138
178
  body: JSON.stringify(body),
139
179
  });
140
- return r.ok;
180
+ if (!r.ok) return { ok: false, skipped: false };
181
+ const j = await r.json().catch(() => ({})) as { skipped?: boolean; skip_reason?: string | null; episode_id?: string };
182
+ return {
183
+ ok: true,
184
+ skipped: j.skipped === true,
185
+ skip_reason: j.skip_reason ?? undefined,
186
+ episode_id: j.episode_id || undefined,
187
+ };
141
188
  } catch (err) {
142
189
  console.warn('[temper] writeEpisode failed', err);
190
+ return { ok: false, skipped: false };
191
+ }
192
+ }
193
+
194
+ /** POST /v1/episodes/bulk — one round trip for ≤200 items. Floor +
195
+ * dedup apply per item; dropped ones land in skipped_count. Prefer
196
+ * this over per-item writeEpisode for summarizer fan-outs — the
197
+ * graph DB behind it is single-worker (api-reference.md §12). */
198
+ async writeEpisodesBulk(items: EpisodeInput[], opts: { saga?: string } = {}): Promise<BulkEpisodeResult> {
199
+ if (!this.enabled || items.length === 0) return { ok: false, episode_ids: [], skipped_count: 0 };
200
+ try {
201
+ const r = await this.fetchRetry423(this.url('/v1/episodes/bulk'), {
202
+ method: 'POST',
203
+ headers: this.headers({ 'content-type': 'application/json' }),
204
+ body: JSON.stringify({
205
+ ...(this.namespace ? { namespace: this.namespace } : {}),
206
+ ...(opts.saga ? { saga: opts.saga } : {}),
207
+ items: items.slice(0, 200).map((ep) => ({
208
+ source_type: 'text',
209
+ reference_time: new Date().toISOString(),
210
+ ...ep,
211
+ })),
212
+ }),
213
+ });
214
+ if (!r.ok) return { ok: false, episode_ids: [], skipped_count: 0 };
215
+ const j = await r.json().catch(() => ({})) as { episode_ids?: string[]; skipped_count?: number };
216
+ return { ok: true, episode_ids: j.episode_ids || [], skipped_count: j.skipped_count || 0 };
217
+ } catch (err) {
218
+ console.warn('[temper] writeEpisodesBulk failed', err);
219
+ return { ok: false, episode_ids: [], skipped_count: 0 };
220
+ }
221
+ }
222
+
223
+ /** PUT /v1/documents/{path} — upsert, revisions kept server-side.
224
+ * The right primitive for chat summaries: ONE doc per chat,
225
+ * overwritten with the latest (api-reference.md §6), instead of an
226
+ * ever-growing pile of chat:* blocks. */
227
+ async putDocument(path: string, doc: DocumentInput): Promise<boolean> {
228
+ if (!this.enabled) return false;
229
+ try {
230
+ const u = this.withNs(new URL(this.url(`/v1/documents/${encodeURIComponent(path)}`)));
231
+ const r = await this.fetchRetry423(u.toString(), {
232
+ method: 'PUT',
233
+ headers: this.headers({ 'content-type': 'application/json' }),
234
+ body: JSON.stringify(doc),
235
+ });
236
+ return r.ok;
237
+ } catch (err) {
238
+ console.warn('[temper] putDocument failed', err);
143
239
  return false;
144
240
  }
145
241
  }
146
242
 
243
+ async getDocument(path: string): Promise<{ title: string; content: string } | null> {
244
+ if (!this.enabled) return null;
245
+ try {
246
+ const u = this.withNs(new URL(this.url(`/v1/documents/${encodeURIComponent(path)}`)));
247
+ const r = await fetch(u.toString(), { headers: this.headers() });
248
+ if (!r.ok) return null;
249
+ const j = await r.json().catch(() => null) as { title?: string; content?: string } | null;
250
+ if (!j || typeof j.content !== 'string') return null;
251
+ return { title: j.title || path, content: j.content };
252
+ } catch (err) {
253
+ console.warn('[temper] getDocument failed', err);
254
+ return null;
255
+ }
256
+ }
257
+
147
258
  async listBlocks(opts: { pinned?: boolean; scope?: 'own' | 'global' | 'both' } = {}): Promise<MemoryBlock[]> {
148
259
  if (!this.enabled) return [];
149
260
  try {
@@ -254,7 +254,7 @@ The Settings modal has these sections:
254
254
  | **Agents** | Detected CLI agents + configuration |
255
255
  | **Profiles** | Agent profiles (CLI + API) — API profiles also power Forge chat |
256
256
  | **Marketplace Providers** | Enterprise marketplace keys — add/remove tenants whose private connector + pipeline + wizard repos overlay on top of public |
257
- | **Memory (Temper)** | Long-term memory backend for the chat agent. When Temper URL+key are blank, chat falls back to a local SQLite store with the same block/episode tools (keyword search only — no semantic/graph search). |
257
+ | **Memory (Temper)** | Long-term memory backend for the chat agent. When Temper URL+key are blank, chat falls back to a local SQLite store with the same block/episode tools (keyword search only — no semantic/graph search). Chat summaries are stored as ONE Temper document per chat (`chats/<session>.md`, overwritten with the latest; local backend stores them as `doc:*` blocks); extracted facts go to episodes only. Duplicate / low-quality writes are rejected server-side and never retried. |
258
258
  | **Chat backend** | Pick the default API profile for chat (extension / CLI / Telegram) |
259
259
  | **Telegram** | Bot token, chat ID, notification toggles |
260
260
  | **Display** | Name, email |
@@ -20,14 +20,9 @@
20
20
 
21
21
  import {
22
22
  cursorKey,
23
- factKey,
24
23
  healthKey,
25
- stableHash,
26
- summaryKey,
27
24
  type CursorValue,
28
- type FactValue,
29
25
  type HealthValue,
30
- type SummaryValue,
31
26
  } from './keys';
32
27
  import { estimateTokens } from './token-estimate';
33
28
  import { compressMessagesForSummarizer } from './compress-messages';
@@ -97,77 +92,63 @@ export async function summarizeAndIngest(args: SummarizeAndIngestArgs): Promise<
97
92
  return { ok: false, error: 'empty summary in response', promptTokens };
98
93
  }
99
94
 
100
- // Persist
95
+ // Persist. Summary goes to the documents primitive — ONE doc per chat,
96
+ // overwritten with the latest (Temper api-reference §6, revisions kept
97
+ // server-side). Replaces the per-cursor chat:* summary blocks that
98
+ // violated the block discipline (§5) and grew without bound.
101
99
  const now = Date.now();
102
- const summaryValue: SummaryValue = {
103
- text: summary,
104
- from_ts: fromTs,
105
- to_ts: toTs,
106
- message_count: messages.length,
107
- model: provider.model,
108
- provider: provider.type,
109
- ingest_ts: now,
110
- };
111
- try {
112
- await store.putBlock(
113
- summaryKey(scope, toTs),
114
- summaryValue,
115
- { description: summary.slice(0, 120), scope: 'own' },
116
- );
117
- } catch (err) {
118
- return { ok: false, error: 'putBlock summary: ' + (err instanceof Error ? err.message : String(err)), promptTokens };
119
- }
120
-
121
- // Dual-write: putBlock above gives us upsert/cursor/recall by key,
122
- // but Temper's KG (Graphiti) only ingests writeEpisode payloads. Push
123
- // the same summary text as an episode so entity/relation extraction
124
- // sees chat history. Failure here doesn't block the pass.
125
100
  try {
126
- await store.writeEpisode({
101
+ const okDoc = await store.putDocument(`chats/${scope}.md`, {
102
+ title: `Chat summary — ${scope}`,
127
103
  content: summary,
128
- source_type: 'text',
129
- source_description: `chat:${scope} summary @ ${toTs}`,
130
- tags: ['summarizer', 'session_summary', `chat:${scope}`],
104
+ tags: ['summarizer', 'chat'],
105
+ source: 'forge-summarizer',
106
+ frontmatter: {
107
+ from_ts: fromTs,
108
+ to_ts: toTs,
109
+ message_count: messages.length,
110
+ model: provider.model,
111
+ provider: provider.type,
112
+ ingest_ts: now,
113
+ },
131
114
  });
115
+ if (!okDoc) {
116
+ return { ok: false, error: 'putDocument summary failed', promptTokens };
117
+ }
132
118
  } catch (err) {
133
- console.warn(`[temper-summary] writeEpisode(summary) failed for ${scope}:`, err instanceof Error ? err.message : err);
119
+ return { ok: false, error: 'putDocument summary: ' + (err instanceof Error ? err.message : String(err)), promptTokens };
134
120
  }
135
121
 
136
- let factCount = 0;
137
- for (const f of facts) {
138
- if (!f.content || !f.subject) continue;
139
- const v: FactValue = {
140
- content: f.content,
141
- subject_kind: f.subject_kind || 'fact',
142
- subject: f.subject,
143
- source_ref: `chat:${scope}@${toTs}`,
144
- confidence: null,
145
- extracted_by: 'summarizer',
146
- };
147
- try {
148
- await store.putBlock(
149
- factKey(f.scope || 'other', f.subject, stableHash(f.content)),
150
- v,
151
- { description: f.content.slice(0, 120), scope: 'own' },
152
- );
153
- factCount += 1;
154
- } catch (err) {
155
- console.warn(`[temper-summary] fact putBlock failed for ${f.subject}:`, err instanceof Error ? err.message : err);
156
- }
157
- // Episode-mirror each fact so the KG sees structured propositions.
158
- // Per-fact rather than batched so Graphiti can attribute extraction
159
- // to the right subject without re-parsing.
160
- try {
161
- await store.writeEpisode({
162
- content: `${f.subject_kind || 'fact'} about ${f.subject}: ${f.content}`,
122
+ // KG ingestion: Temper's graph (Graphiti) only extracts from episodes.
123
+ // Bundle summary + per-fact propositions into ONE bulk call — the graph
124
+ // DB is single-worker; per-item round trips were its worst access
125
+ // pattern (api-reference §12). skipped_count = server-side dedup /
126
+ // quality-floor drops; rejections, not failures — never retried.
127
+ // Fact blocks (fact:*) are gone entirely: facts live in episodes only,
128
+ // per the documented block discipline.
129
+ const factItems = facts.filter((f) => f.content && f.subject);
130
+ try {
131
+ const bulk = await store.writeEpisodesBulk([
132
+ {
133
+ content: summary,
163
134
  source_type: 'text',
135
+ source_description: `chat:${scope} summary @ ${toTs}`,
136
+ tags: ['summarizer', 'session_summary', `chat:${scope}`],
137
+ },
138
+ ...factItems.map((f) => ({
139
+ content: `${f.subject_kind || 'fact'} about ${f.subject}: ${f.content}`,
140
+ source_type: 'text' as const,
164
141
  source_description: `chat:${scope} fact: ${f.subject}`,
165
142
  tags: ['summarizer', 'fact', f.scope || 'other', f.subject_kind || 'fact'],
166
- });
167
- } catch (err) {
168
- console.warn(`[temper-summary] writeEpisode(fact) failed for ${f.subject}:`, err instanceof Error ? err.message : err);
143
+ })),
144
+ ]);
145
+ if (bulk.skipped_count > 0) {
146
+ console.log(`[temper-summary] ${scope}: ${bulk.skipped_count} episode(s) skipped by server (dedup/quality floor)`);
169
147
  }
148
+ } catch (err) {
149
+ console.warn(`[temper-summary] writeEpisodesBulk failed for ${scope}:`, err instanceof Error ? err.message : err);
170
150
  }
151
+ const factCount = factItems.length;
171
152
 
172
153
  // Advance cursor (B9)
173
154
  const newCursor: CursorValue = {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * openPortal — open a vendor login / portal URL in the RIGHT browser.
3
+ *
4
+ * Mac-native Forge: the UI runs in the user's own browser, which holds
5
+ * their SSO cookies → window.open is correct.
6
+ *
7
+ * Container deploy: the UI renders in the user's browser but the agent
8
+ * drives a Chromium INSIDE the container, where all SSO cookies live.
9
+ * window.open would land in the host browser (no corp session, useless).
10
+ * So when an extension is connected — the same signal the Login-status
11
+ * probe already relies on — route the open through the bridge so the
12
+ * extension runs chrome.tabs.create in the container Chromium.
13
+ *
14
+ * Pure client helper (fetch + DOM). Reference/help links should keep
15
+ * using a plain window.open / <a target="_blank"> — they genuinely
16
+ * belong in the user's own browser.
17
+ */
18
+
19
+ function portalToast(msg: string): void {
20
+ if (typeof document === 'undefined') return;
21
+ const el = document.createElement('div');
22
+ el.textContent = msg;
23
+ el.style.cssText =
24
+ 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:99999;' +
25
+ 'background:#1e293b;color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;' +
26
+ 'box-shadow:0 4px 12px rgba(0,0,0,.3);opacity:0;transition:opacity .2s';
27
+ document.body.appendChild(el);
28
+ requestAnimationFrame(() => { el.style.opacity = '1'; });
29
+ setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 3000);
30
+ }
31
+
32
+ export async function openPortal(url: string): Promise<void> {
33
+ if (!url) return;
34
+ try {
35
+ const status = await fetch('/api/browser-bridge?action=status')
36
+ .then((r) => (r.ok ? r.json() : null))
37
+ .catch(() => null);
38
+ const connected = Number(status?.connected_extensions || 0) > 0;
39
+ if (connected) {
40
+ const r = await fetch('/api/browser-bridge', {
41
+ method: 'POST',
42
+ headers: { 'content-type': 'application/json' },
43
+ body: JSON.stringify({
44
+ action: 'rpc',
45
+ method: 'browser.open_tab',
46
+ params: { url, active: true },
47
+ }),
48
+ });
49
+ // proxy returns the bridge rpc envelope: {ok, value} | {ok:false, error}
50
+ const j = await r.json().catch(() => null);
51
+ if (r.ok && j && j.ok !== false) {
52
+ portalToast('Opened in workspace browser (visible via stream)');
53
+ return;
54
+ }
55
+ }
56
+ } catch {
57
+ /* fall through to host-browser open */
58
+ }
59
+ window.open(url, '_blank', 'noopener');
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.75",
3
+ "version": "0.10.77",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {