Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "./src/store/store.ts";
import { createCompleteFn, resolveProvider } from "./src/engine/llm.ts";
import { createEmbedFn } from "./src/engine/embed.ts";
import { estimateTokens } from "./src/tokens.ts";
import { Recaller, parseTimeRange } from "./src/recaller/recall.ts";
import { Extractor } from "./src/extractor/extract.ts";
import { assembleContext } from "./src/format/assemble.ts";
Expand Down Expand Up @@ -158,17 +159,21 @@ function estimateMsgTokens(msg: any): number {
const text = typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content ?? "");
return Math.ceil(text.length / 3);
return estimateTokens(text.length);
}

/** 从 content blocks 数组抽取纯文本(text block 拼接) */
function textFromBlocks(blocks: any[]): string {
return blocks
.filter((b: any) => b && typeof b === "object" && b.type === "text" && typeof b.text === "string")
.map((b: any) => b.text)
.join("\n");
}

export function extractAssistantText(msg: any): string {
if (typeof msg.content === "string") return msg.content;
if (!Array.isArray(msg.content)) return "";
return msg.content
.filter((b: any) => b && typeof b === "object" && b.type === "text" && typeof b.text === "string")
.map((b: any) => b.text)
.join("\n")
.trim();
return textFromBlocks(msg.content).trim();
}

export function extractUserText(msg: any): string {
Expand All @@ -178,11 +183,7 @@ export function extractUserText(msg: any): string {
} else if (!Array.isArray(msg.content)) {
raw = String(msg.content ?? "");
} else {
raw = msg.content
.filter((b: any) => b && typeof b === "object" && b.type === "text" && typeof b.text === "string")
.map((b: any) => b.text)
.join("\n")
.trim();
raw = textFromBlocks(msg.content).trim();
}
// 去掉 OpenClaw metadata(Sender JSON block、命令前缀、时间戳)
const fenceEnd = raw.lastIndexOf("```");
Expand Down
25 changes: 2 additions & 23 deletions src/engine/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,11 @@
*/

import type { EmbeddingConfig } from "../types.ts";
import { fetchRetry } from "./http.ts";

export type EmbedMode = "db" | "query";
export type EmbedFn = (text: string, mode?: EmbedMode) => Promise<number[]>;

// ─── 带重试 + 超时的 fetch ─────────────────────────────────────

const RETRYABLE = new Set([429, 500, 502, 503, 529]);

async function fetchRetry(url: string, init: RequestInit, retries = 3, timeoutMs = 10_000): Promise<Response> {
for (let i = 0; i <= retries; i++) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { ...init, signal: ctrl.signal });
clearTimeout(t);
if (res.ok || i >= retries || !RETRYABLE.has(res.status)) return res;
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)));
} catch (err: any) {
clearTimeout(t);
if (i >= retries) throw err;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
throw new Error("[graph-memory-pro] embed fetch failed after retries");
}

// ─── Provider 识别 ───────────────────────────────────────────

/**
Expand Down Expand Up @@ -105,7 +84,7 @@ export async function createEmbedFn(cfg: EmbeddingConfig | undefined): Promise<E
...(apiKey ? { "Authorization": `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify(buildBody(input, mode)),
});
}, { timeoutMs: 10_000, label: "[graph-memory-pro] Embedding", retryOnTimeout: true });

if (!res.ok) {
const errText = await res.text().catch(() => "");
Expand Down
82 changes: 82 additions & 0 deletions src/engine/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* graph-memory-pro — 共享 HTTP 客户端工具
*
* 统一 LLM / Embedding 调用的超时与重试语义(此前 llm.ts / embed.ts 各有一份
* fetchRetry,且已发生行为分叉:llm 侧在重构中丢失了网络异常重试分支)。
*
* 语义:
* - 可重试 HTTP 状态码(429/5xx):指数退避重试
* - 网络级异常(连接失败、连接被重置等):同样重试
* - 超时:默认立即抛出 HttpTimeoutError;仅当 retryOnTimeout=true 时计入重试
* (LLM 调用耗时长,超时重试会成倍拉长最坏阻塞时间;embedding 调用短,重试无害)
*/

const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 529]);

/** 请求超时(AbortError 的友好化包装;instanceof 可与网络异常区分) */
export class HttpTimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = "HttpTimeoutError";
}
}

export interface FetchRetryOptions {
/** 最大重试次数(不含首次),默认 3 */
retries?: number;
/** 单次请求超时,默认 30_000ms */
timeoutMs?: number;
/** 错误信息前缀(如 "[graph-memory] LLM"),保持各调用方原报错格式 */
label?: string;
/** 超时是否计入重试;默认 false(立即抛出) */
retryOnTimeout?: boolean;
/** 重试退避函数,attempt 从 0 开始;默认指数退避 1s/2s/4s…(测试可注入 0) */
backoffMs?: (attempt: number) => number;
}

export async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number,
label = "[graph-memory]",
): Promise<Response> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: ctrl.signal });
} catch (err: any) {
if (err?.name === "AbortError") {
throw new HttpTimeoutError(`${label} request timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}

export async function fetchRetry(
url: string,
init: RequestInit,
opts: FetchRetryOptions = {},
): Promise<Response> {
const {
retries = 3,
timeoutMs = 30_000,
label = "[graph-memory]",
retryOnTimeout = false,
backoffMs = (attempt: number) => 1000 * Math.pow(2, attempt),
} = opts;

for (let i = 0; i <= retries; i++) {
try {
const res = await fetchWithTimeout(url, init, timeoutMs, label);
if (res.ok || i >= retries || !RETRYABLE_STATUS.has(res.status)) return res;
await new Promise((r) => setTimeout(r, backoffMs(i)));
} catch (err) {
if (err instanceof HttpTimeoutError && !retryOnTimeout) throw err;
if (i >= retries) throw err;
await new Promise((r) => setTimeout(r, backoffMs(i)));
}
}
throw new Error(`${label} request failed after retries`);
}
42 changes: 4 additions & 38 deletions src/engine/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
extractOutputTextFromSse,
} from "./oauth.ts";
import type { OAuthSession } from "./oauth.ts";
import { fetchRetry } from "./http.ts";

export type LlmProvider = "openai" | "anthropic" | "oauth";

Expand Down Expand Up @@ -84,41 +85,6 @@ export function resolveProvider(cfg: LlmConfig | undefined): {
return { provider: inferred, inferred: true };
}

async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number,
): Promise<Response> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: ctrl.signal });
} catch (err: any) {
if (err?.name === "AbortError") {
throw new Error(`[graph-memory] LLM request timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}

const RETRYABLE = new Set([429, 500, 502, 503, 529]);

async function fetchRetry(
url: string,
init: RequestInit,
retries: number,
timeoutMs: number,
): Promise<Response> {
for (let i = 0; i <= retries; i++) {
const res = await fetchWithTimeout(url, init, timeoutMs);
if (res.ok || i >= retries || !RETRYABLE.has(res.status)) return res;
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, i)));
}
throw new Error("[graph-memory] fetch failed after retries");
}

/**
* 构造 LLM CompleteFn。
*
Expand Down Expand Up @@ -220,7 +186,7 @@ export function createCompleteFn(
stream: false,
text: { format: { type: "text" } },
}),
}, 3, timeoutMs);
}, { retries: 3, timeoutMs, label: "[graph-memory] LLM" });

if (!res.ok) {
const errText = await res.text().catch(() => "");
Expand Down Expand Up @@ -274,7 +240,7 @@ export function createCompleteFn(
system,
messages: [{ role: "user", content: user }],
}),
}, 3, timeoutMs);
}, { retries: 3, timeoutMs, label: "[graph-memory] LLM" });
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new Error(`[graph-memory] Anthropic API ${res.status}: ${errText.slice(0, 200)}`);
Expand Down Expand Up @@ -319,7 +285,7 @@ export function createCompleteFn(
max_tokens: maxTokens,
temperature: 0.1,
}),
}, 3, timeoutMs);
}, { retries: 3, timeoutMs, label: "[graph-memory] LLM" });
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new Error(`[graph-memory] LLM API ${res.status}: ${errText.slice(0, 200)}`);
Expand Down
11 changes: 1 addition & 10 deletions src/extractor/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { ExtractionResult, FinalizeResult } from "../types.ts";
import { EDGE_TYPES, isValidEdgeDirection } from "../types.ts";
import type { CompleteFn } from "../engine/llm.ts";
import { normalizeName } from "../store/store.ts";

// ─── 节点/边合法值 ──────────────────────────────────────────────

Expand Down Expand Up @@ -159,16 +160,6 @@ ${JSON.stringify(nodes.map(n => ({
<Graph Summary>
${summary}`;

// ─── 名称标准化(与 store.ts 一致)────────────────────────────

export function normalizeName(name: string): string {
return name.trim().toLowerCase()
.replace(/[\s_]+/g, "-")
.replace(/[^a-z0-9\u4e00-\u9fff\-]/g, "")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "");
}

// ─── 边类型自动修正 ─────────────────────────────────────────────

/**
Expand Down
5 changes: 2 additions & 3 deletions src/format/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
import type { Driver } from "neo4j-driver";
import type { GmNode, GmEdge } from "../types.ts";
import { getCommunitySummary, type CommunitySummary } from "../store/store.ts";

const CHARS_PER_TOKEN = 3;
import { CHARS_PER_TOKEN, estimateTokens } from "../tokens.ts";

export function buildSystemPromptAddition(params: {
selectedNodes: Array<{ type: string; src: "active" | "recalled" }>;
Expand Down Expand Up @@ -180,7 +179,7 @@ export async function assembleContext(
});

const fullContent = systemPrompt + "\n\n" + xml;
return { xml, systemPrompt, tokens: Math.ceil(fullContent.length / CHARS_PER_TOKEN) };
return { xml, systemPrompt, tokens: estimateTokens(fullContent.length) };
}

function escapeXml(s: string): string {
Expand Down
15 changes: 5 additions & 10 deletions src/recaller/recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export class Recaller {
seeds = await searchNodes(this.driver, query, limit);
}

if (!seeds.length) return { nodes: [], edges: [], tokenEstimate: 0 };
if (!seeds.length) return { nodes: [], edges: [] };

const seedIds = seeds.map(n => n.id);

Expand All @@ -161,7 +161,7 @@ export class Recaller {
this.cfg.recallMaxDepth,
);

if (!nodes.length) return { nodes: [], edges: [], tokenEstimate: 0 };
if (!nodes.length) return { nodes: [], edges: [] };

// PPR 排序
const candidateIds = nodes.map(n => n.id);
Expand All @@ -182,7 +182,6 @@ export class Recaller {
return {
nodes: filtered,
edges: edges.filter(e => ids.has(e.fromId) && ids.has(e.toId)),
tokenEstimate: this.estimateTokens(filtered),
};
}

Expand Down Expand Up @@ -213,11 +212,11 @@ export class Recaller {
seeds = await communityRepresentatives(this.driver, 2);
}

if (!seeds.length) return { nodes: [], edges: [], tokenEstimate: 0 };
if (!seeds.length) return { nodes: [], edges: [] };

const seedIds = seeds.map(n => n.id);
const { nodes, edges } = await graphWalk(this.driver, seedIds, 1);
if (!nodes.length) return { nodes: [], edges: [], tokenEstimate: 0 };
if (!nodes.length) return { nodes: [], edges: [] };

const candidateIds = nodes.map(n => n.id);
const { scores: pprScores } = await personalizedPageRank(
Expand All @@ -237,7 +236,6 @@ export class Recaller {
return {
nodes: filtered,
edges: edges.filter(e => ids.has(e.fromId) && ids.has(e.toId)),
tokenEstimate: this.estimateTokens(filtered),
};
}

Expand All @@ -261,12 +259,9 @@ export class Recaller {

const nodes = Array.from(nodeMap.values());
const edges = Array.from(edgeMap.values());
return { nodes, edges, tokenEstimate: this.estimateTokens(nodes) };
return { nodes, edges };
}

private estimateTokens(nodes: GmNode[]): number {
return Math.ceil(nodes.reduce((s, n) => s + n.content.length + n.description.length, 0) / 3);
}

async syncEmbed(node: GmNode): Promise<void> {
if (!this.embed) return;
Expand Down
4 changes: 2 additions & 2 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import type { Driver } from "neo4j-driver";
import neo4j from "neo4j-driver";
import { createHash } from "crypto";
import { createHash, randomUUID } from "crypto";
import type { GmNode, GmEdge, EdgeType, NodeType, NodeTier } from "../types.ts";
import { NODE_TYPE_TO_LABEL, isValidEdgeDirection, EDGE_TYPES } from "../types.ts";
import { getSession } from "./db.ts";
Expand All @@ -20,7 +20,7 @@ function nint(v: number): any {
// ─── 工具 ─────────────────────────────────────────────────────

function uid(p: string): string {
return `${p}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return `${p}-${randomUUID()}`;
}

function toNode(r: any): GmNode {
Expand Down
13 changes: 13 additions & 0 deletions src/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* graph-memory-pro — token 估算
*
* 统一的字符→token 粗估换算(约 3 字符 = 1 token,中英混合文本的经验值)。
* 全仓库所有 token 估算必须经由本模块,避免系数多处漂移。
*/

export const CHARS_PER_TOKEN = 3;

/** 按字符数粗估 token 数(向上取整) */
export function estimateTokens(chars: number): number {
return Math.ceil(chars / CHARS_PER_TOKEN);
}
1 change: 0 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ export interface FinalizeResult {
export interface RecallResult {
nodes: GmNode[];
edges: GmEdge[];
tokenEstimate: number;
}

// ─── Embedding 配置 ──────────────────────────────────────────
Expand Down
Loading