@aion0/forge 0.10.75 → 0.10.76
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/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/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.76
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-12
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.75
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
8
|
+
- feat(memory): align Temper client with documented API contract
|
|
9
|
+
- fix(chat): drain leftover notes at turn end — late-merged input no longer lost
|
|
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.75...v0.10.76
|
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 = {
|
package/package.json
CHANGED