@aion0/forge 0.10.74 → 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 CHANGED
@@ -1,8 +1,12 @@
1
- # Forge v0.10.74
1
+ # Forge v0.10.76
2
2
 
3
- Released: 2026-06-11
3
+ Released: 2026-06-12
4
4
 
5
- ## Changes since v0.10.73
5
+ ## Changes since v0.10.75
6
6
 
7
+ ### Other
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
7
10
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.73...v0.10.74
11
+
12
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.75...v0.10.76
@@ -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 = {
package/lib/pipeline.ts CHANGED
@@ -1663,13 +1663,20 @@ function reapOrphanedPipelineTasks() {
1663
1663
  if (reapedOrphans) return;
1664
1664
  reapedOrphans = true;
1665
1665
  try {
1666
+ // This timer arms on MODULE load, which in a lazy-loading Next.js worker
1667
+ // can be minutes after the actual server boot — by then this very boot
1668
+ // may have live tasks. Only treat a task as a previous-boot corpse if it
1669
+ // started BEFORE this process did; a live task can never predate its
1670
+ // own parent.
1671
+ const bootAt = Date.now() - process.uptime() * 1000;
1666
1672
  const pipelines = listPipelines().filter(p => p.status === 'running');
1667
1673
  let reaped = 0;
1668
1674
  for (const pipeline of pipelines) {
1669
1675
  for (const node of Object.values(pipeline.nodes)) {
1670
1676
  if (node.status === 'running' && node.taskId) {
1671
1677
  const t = getTask(node.taskId);
1672
- if (t && t.status === 'running') {
1678
+ const tStart = Date.parse(t?.startedAt || t?.createdAt || '') || 0;
1679
+ if (t && t.status === 'running' && tStart > 0 && tStart < bootAt) {
1673
1680
  try {
1674
1681
  cancelTask(node.taskId);
1675
1682
  reaped += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.74",
3
+ "version": "0.10.76",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {