@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 +6 -5
- package/components/ConnectorsPanel.tsx +3 -3
- package/components/EnterpriseBadge.tsx +5 -5
- package/components/LoginStatusPanel.tsx +2 -1
- package/components/OnboardingWizard.tsx +4 -4
- package/lib/chat/agent-loop.ts +24 -0
- package/lib/chat/build-memory-context.ts +5 -0
- package/lib/chat/local-memory.ts +36 -5
- package/lib/chat/memory-store.ts +13 -3
- package/lib/chat/memory-tools.ts +6 -2
- package/lib/chat/temper.ts +115 -4
- package/lib/help-docs/01-settings.md +1 -1
- package/lib/memory/temper-summary.ts +45 -64
- package/lib/ui/openPortal.ts +60 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.77
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-12
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.76
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
- fix(
|
|
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.
|
|
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
|
-
|
|
933
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
750
|
-
|
|
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}
|
|
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
|
)}
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -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;
|
package/lib/chat/local-memory.ts
CHANGED
|
@@ -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<
|
|
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[]> {
|
package/lib/chat/memory-store.ts
CHANGED
|
@@ -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<
|
|
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
|
}
|
package/lib/chat/memory-tools.ts
CHANGED
|
@@ -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
|
|
153
|
-
|
|
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
|
];
|
package/lib/chat/temper.ts
CHANGED
|
@@ -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<
|
|
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
|
|
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
|
-
|
|
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.
|
|
101
|
+
const okDoc = await store.putDocument(`chats/${scope}.md`, {
|
|
102
|
+
title: `Chat summary — ${scope}`,
|
|
127
103
|
content: summary,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
119
|
+
return { ok: false, error: 'putDocument summary: ' + (err instanceof Error ? err.message : String(err)), promptTokens };
|
|
134
120
|
}
|
|
135
121
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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