Files
moreminimore-vibe/dyad-remove-limit-doc/Changelogs
Kunthawat Greethong 5660de49de
Some checks failed
CI / test (map[image:macos-latest name:macos], 1, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 2, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 3, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 4, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 1, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 2, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 3, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 4, 4) (push) Has been cancelled
CI / merge-reports (push) Has been cancelled
feat: integrate custom features for smart context management
- Added a new integration script to manage custom features related to smart context.
- Implemented handlers for smart context operations (get, update, clear, stats) in ipc.
- Created a SmartContextStore class to manage context snippets and summaries.
- Developed hooks for React to interact with smart context (useSmartContext, useUpdateSmartContext, useClearSmartContext, useSmartContextStats).
- Included backup and restore functionality in the integration script.
- Validated integration by checking for custom modifications and file existence.
2025-12-18 15:56:48 +07:00

1011 lines
31 KiB
Plaintext

5 commits
14 files changed
3 contributors
Commits on Aug 26, 2025
Remove rate limit simulation from fake LLM server (#1)
@pesachnps
@cursoragent
3 people authored on Aug 26
Add payload size limits and truncation for chat context (#2)
@pesachnps
@cursoragent
3 people authored on Aug 26
Update README with detailed installation, build, and development inst…
@pesachnps
@cursoragent
3 people authored on Aug 26
Update GitHub repository link in PromoMessage component (#4)
@pesachnps
@cursoragent
3 people authored on Aug 26
Add smart context retrieval for enhanced chat memory and context (#5)
@pesachnps
@cursoragent
3 people authored on Aug 26
Showing with 666 additions and 66 deletions.
81 changes: 72 additions & 9 deletions81
README.md
Original file line number Diff line number Diff line change
@@ -2,28 +2,91 @@
Dyad is a local, open-source AI app builder. It's fast, private, and fully under your control — like Lovable, v0, or Bolt, but running right on your machine.
[![Image](https://github.com/user-attachments/assets/f6c83dfc-6ffd-4d32-93dd-4b9c46d17790)](http://dyad.sh/)
More info at: [http://dyad.sh/](http://dyad.sh/)
![Image](https://github.com/user-attachments/assets/f6c83dfc-6ffd-4d32-93dd-4b9c46d17790)
## 🚀 Features
- ⚡️ **Local**: Fast, private and no lock-in.
- 🛠 **Bring your own keys**: Use your own AI API keys — no vendor lock-in.
- 🖥️ **Cross-platform**: Easy to run on Mac or Windows.
## 📦 Download
## 🧰 Prerequisites
- Node.js >= 20
- npm (comes with Node.js)
- Git
You can verify your versions:
```bash
node -v
npm -v
```
## 🏗️ Install (from source)
```bash
git clone https://github.com/dyad-sh/dyad.git
cd dyad
npm install
```
## ▶️ Run locally (development)
- Start the app with the default configuration:
```bash
npm start
```
- Optionally, point the app to a locally running engine (on http://localhost:8080/v1):
```bash
npm run dev:engine
```
No sign-up required. Just download and go.
### Environment variables (optional)
### [👉 Download for your platform](https://www.dyad.sh/#download)
- `DYAD_ENGINE_URL`: URL of the Dyad engine (defaults to built-in configuration).
- `DYAD_GATEWAY_URL`: URL of a compatible gateway if you prefer to route requests.
Example:
```bash
DYAD_ENGINE_URL=http://localhost:8080/v1 npm start
```
## 📦 Build installers (make)
Create platform-specific distributables:
```bash
npm run make
```
Outputs are written to the `out/` directory.
## 🧪 Tests and linting
```bash
# Unit tests
npm test
# Lint
npm run lint
# Prettier check
npm run prettier:check
```
## 🤝 Community
Join our growing community of AI app builders on **Reddit**: [r/dyadbuilders](https://www.reddit.com/r/dyadbuilders/) - share your projects and get help from the community!
Join our growing community of AI app builders on **Reddit**: [r/dyadbuilders](https://www.reddit.com/r/dyadbuilders/) — share your projects and get help from the community!
## 🛠️ Contributing
**Dyad** is open-source (Apache 2.0 licensed).
If you're interested in contributing to Dyad, please read our [contributing](./CONTRIBUTING.md) doc.
## 📄 License
If you're interested in contributing to dyad, please read our [contributing](./CONTRIBUTING.md) doc.
MIT License — see [LICENSE](./LICENSE).
10 changes: 5 additions & 5 deletions10
package-lock.json
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
40 changes: 36 additions & 4 deletions40
src/components/HelpDialog.tsx
Original file line number Diff line number Diff line change
@@ -155,11 +155,43 @@ ${debugInfo.logs.slice(-3_500) || "No logs available"}
setIsUploading(true);
try {
// Prepare data for upload
// Prepare data for upload with pruning to avoid oversized payloads
const MAX_LOG_CHARS = 150_000;
const MAX_CODEBASE_CHARS = 200_000;
const MAX_MESSAGES = 200; // recent messages
const MAX_MESSAGE_CHARS = 8_000; // per message
const logs = chatLogsData.debugInfo?.logs || "";
const prunedLogs = logs.length > MAX_LOG_CHARS
? logs.slice(-MAX_LOG_CHARS)
: logs;
const codebase = chatLogsData.codebase || "";
const prunedCodebase = codebase.length > MAX_CODEBASE_CHARS
? codebase.slice(0, MAX_CODEBASE_CHARS) +
`\n\n[Truncated codebase snippet: original length ${codebase.length}]`
: codebase;
const allMessages = chatLogsData.chat?.messages || [];
const recentMessages = allMessages.slice(-MAX_MESSAGES).map((m) => ({
...m,
content:
typeof m.content === "string" && m.content.length > MAX_MESSAGE_CHARS
? m.content.slice(0, MAX_MESSAGE_CHARS) +
`\n\n[Truncated message: original length ${m.content.length}]`
: m.content,
}));
const chatLogsJson = {
systemInfo: chatLogsData.debugInfo,
chat: chatLogsData.chat,
codebaseSnippet: chatLogsData.codebase,
systemInfo: {
...chatLogsData.debugInfo,
logs: prunedLogs,
},
chat: {
...chatLogsData.chat,
messages: recentMessages,
},
codebaseSnippet: prunedCodebase,
};
// Get signed URL
2 changes: 1 addition & 1 deletion2
src/components/chat/PromoMessage.tsx
Original file line number Diff line number Diff line change
@@ -192,7 +192,7 @@ export const GITHUB_TIP: MessageConfig = {
{
type: "link",
content: "GitHub",
url: "https://github.com/dyad-sh/dyad",
url: "https://github.com/pesachnps/dyad-without-the-limits",
},
],
};
61 changes: 61 additions & 0 deletions61
src/hooks/useSmartContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type {
SmartContextMeta,
SmartContextSnippet,
SmartContextRetrieveResult,
} from "@/ipc/ipc_types";
export function useSmartContextMeta(chatId: number) {
return useQuery<SmartContextMeta, Error>({
queryKey: ["smart-context", chatId, "meta"],
queryFn: async () => {
const ipc = IpcClient.getInstance();
return ipc.getSmartContextMeta(chatId);
},
enabled: !!chatId,
});
}
export function useRetrieveSmartContext(
chatId: number,
query: string,
budgetTokens: number,
) {
return useQuery<SmartContextRetrieveResult, Error>({
queryKey: ["smart-context", chatId, "retrieve", query, budgetTokens],
queryFn: async () => {
const ipc = IpcClient.getInstance();
return ipc.retrieveSmartContext({ chatId, query, budgetTokens });
},
enabled: !!chatId && !!query && budgetTokens > 0,
meta: { showErrorToast: true },
});
}
export function useUpsertSmartContextSnippets(chatId: number) {
const qc = useQueryClient();
return useMutation<number, Error, Array<Pick<SmartContextSnippet, "text" | "source">>>({
mutationFn: async (snippets) => {
const ipc = IpcClient.getInstance();
return ipc.upsertSmartContextSnippets(chatId, snippets);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["smart-context", chatId] });
},
});
}
export function useUpdateRollingSummary(chatId: number) {
const qc = useQueryClient();
return useMutation<SmartContextMeta, Error, { summary: string }>({
mutationFn: async ({ summary }) => {
const ipc = IpcClient.getInstance();
return ipc.updateSmartContextRollingSummary(chatId, summary);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["smart-context", chatId, "meta"] });
},
});
}
137 changes: 125 additions & 12 deletions137
src/ipc/handlers/chat_stream_handlers.ts
Original file line number Diff line number Diff line change
@@ -38,7 +38,7 @@ import * as path from "path";
import * as os from "os";
import * as crypto from "crypto";
import { readFile, writeFile, unlink } from "fs/promises";
import { getMaxTokens, getTemperature } from "../utils/token_utils";
import { getMaxTokens, getTemperature, getContextWindow } from "../utils/token_utils";
import { MAX_CHAT_TURNS_IN_CONTEXT } from "@/constants/settings_constants";
import { validateChatContext } from "../utils/context_paths_utils";
import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google";
@@ -64,11 +64,31 @@ import { parseAppMentions } from "@/shared/parse_mention_apps";
import { prompts as promptsTable } from "../../db/schema";
import { inArray } from "drizzle-orm";
import { replacePromptReference } from "../utils/replacePromptReference";
import {
appendSnippets,
retrieveContext as retrieveSmartContext,
updateRollingSummary as updateSmartContextRollingSummary,
} from "../utils/smart_context_store";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
const logger = log.scope("chat_stream_handlers");
// Request size safeguards
const MAX_CODEBASE_CHARS = 200_000; // ~200k chars for codebase context
const MAX_OTHER_APPS_CODEBASE_CHARS = 200_000; // cap referenced apps section
const MAX_TEXT_ATTACHMENT_CHARS = 50_000; // per text attachment
const MAX_IMAGE_BYTES = 3 * 1024 * 1024; // 3MB per image
function truncateWithNotice(input: string, limit: number, label: string): string {
if (input.length <= limit) return input;
const head = input.slice(0, limit);
return (
head +
`\n\n[Truncated ${label}: original length ${input.length.toLocaleString()} > limit ${limit.toLocaleString()}]`
);
}
// Track active streams for cancellation
const activeStreams = new Map<number, AbortController>();
@@ -437,6 +457,12 @@ ${componentSnippet}
);
}
const cappedOtherAppsCodebaseInfo = truncateWithNotice(
otherAppsCodebaseInfo,
MAX_OTHER_APPS_CODEBASE_CHARS,
"referenced apps codebase context",
);
logger.log(`Extracted codebase information from ${appPath}`);
logger.log(
"codebaseInfo: length",
@@ -450,6 +476,13 @@ ${componentSnippet}
files,
);
// Apply size caps to codebase snippets prior to prompt construction
const cappedCodebaseInfo = truncateWithNotice(
codebaseInfo,
MAX_CODEBASE_CHARS,
"codebase context",
);
// Prepare message history for the AI
const messageHistory = updatedChat.messages.map((message) => ({
role: message.role as "user" | "assistant" | "system",
@@ -576,36 +609,72 @@ This conversation includes one or more image attachments. When the user uploads
`;
}
const codebasePrefix = isEngineEnabled
? // No codebase prefix if engine is set, we will take of it there.
[]
const codebasePrefix = settings.enableProSmartFilesContextMode
? ([] as const)
: ([
{
role: "user",
content: createCodebasePrompt(codebaseInfo),
content: createCodebasePrompt(cappedCodebaseInfo),
},
{
role: "assistant",
content: "OK, got it. I'm ready to help",
content:
"Thanks for the codebase. I will use it to answer your questions.",
},
] as const);
const otherCodebasePrefix = otherAppsCodebaseInfo
const otherCodebasePrefix = cappedOtherAppsCodebaseInfo
? ([
{
role: "user",
content: createOtherAppsCodebasePrompt(otherAppsCodebaseInfo),
content: createOtherAppsCodebasePrompt(cappedOtherAppsCodebaseInfo),
},
{
role: "assistant",
content: "OK.",
content:
"Got it. I will use the referenced apps' codebases for read-only context.",
},
] as const)
: ([] as const);
// Smart Context: retrieve rolling summary + top snippets within a budget
const contextWindow = await getContextWindow();
// Reserve budget: 20% system/instructions, 20% output, 15% summary, rest for snippets
const availableForContext = Math.floor(contextWindow * 0.45);
const retrieval = await retrieveSmartContext(
req.chatId,
req.prompt,
Math.max(0, availableForContext),
);
const summaryPrefix: ModelMessage[] = retrieval.rollingSummary
? ([
{
role: "user",
content: `Rolling summary:\n${retrieval.rollingSummary}`,
},
{ role: "assistant", content: "Okay." },
] as const)
: [];
: ([] as const);
const snippetsText = retrieval.snippets
.map((s) => `- ${s.text}`)
.join("\n\n");
const snippetsPrefix: ModelMessage[] = snippetsText
? ([
{
role: "user",
content: `Relevant context snippets (compressed):\n${snippetsText}`,
},
{ role: "assistant", content: "Noted." },
] as const)
: ([] as const);
let chatMessages: ModelMessage[] = [
...codebasePrefix,
...otherCodebasePrefix,
...summaryPrefix,
...snippetsPrefix,
...limitedMessageHistory.map((msg) => ({
role: msg.role as "user" | "assistant" | "system",
// Why remove thinking tags?
@@ -868,6 +937,12 @@ ${problemReport.problems
chatContext,
virtualFileSystem,
});
const cappedCodebaseInfoForContinuation = truncateWithNotice(
codebaseInfo,
MAX_CODEBASE_CHARS,
"codebase context",
);
const { modelClient } = await getModelClient(
settings.selectedModel,
settings,
@@ -886,7 +961,7 @@ ${problemReport.problems
) {
return {
role: "user",
content: createCodebasePrompt(codebaseInfo),
content: createCodebasePrompt(cappedCodebaseInfoForContinuation),
} as const;
}
return msg;
@@ -1055,6 +1130,21 @@ ${problemReport.problems
}
}
// Append Smart Context snippets from this turn and update rolling summary title if present
try {
// Add user prompt and assistant response as snippets for future retrieval
await appendSnippets(req.chatId, [
{ text: userPrompt, source: { type: "message" } },
{ text: fullResponse, source: { type: "message" } },
]);
const summaryMatch = fullResponse.match(/<dyad-chat-summary>(.*?)<\/dyad-chat-summary>/);
if (summaryMatch && summaryMatch[1]) {
await updateSmartContextRollingSummary(req.chatId, summaryMatch[1]);
}
} catch (e) {
logger.warn("smart-context post-write failed", e);
}
// Return the chat ID for backwards compatibility
return req.chatId;
} catch (error) {
@@ -1135,6 +1225,12 @@ async function replaceTextAttachmentWithContent(
// Read the full content
const fullContent = await readFile(filePath, "utf-8");
const contentToInclude = truncateWithNotice(
fullContent,
MAX_TEXT_ATTACHMENT_CHARS,
`attachment ${fileName}`,
);
// Replace the placeholder tag with the full content
const escapedPath = filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const tagPattern = new RegExp(
@@ -1144,7 +1240,7 @@ async function replaceTextAttachmentWithContent(
const replacedText = text.replace(
tagPattern,
`Full content of ${fileName}:\n\`\`\`\n${fullContent}\n\`\`\``,
`Full content of ${fileName}:\n\`\`\`\n${contentToInclude}\n\`\`\``,
);
logger.log(
@@ -1193,10 +1289,17 @@ async function prepareMessageWithAttachments(
});
// Add image parts for any image attachments
const omittedImages: string[] = [];
for (const filePath of attachmentPaths) {
const ext = path.extname(filePath).toLowerCase();
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) {
try {
// Check size before reading
const stats = await fs.promises.stat(filePath);
if (stats.size > MAX_IMAGE_BYTES) {
omittedImages.push(path.basename(filePath));
continue;
}
// Read the file as a buffer
const imageBuffer = await readFile(filePath);
@@ -1213,6 +1316,16 @@ async function prepareMessageWithAttachments(
}
}
if (omittedImages.length > 0) {
contentParts.push({
type: "text",
text:
`Note: ${omittedImages.length} image(s) omitted due to size limits (>${Math.floor(
MAX_IMAGE_BYTES / (1024 * 1024),
)}MB): ` + omittedImages.join(", "),
});
}
// Return the message with the content array
return {
role: "user",
66 changes: 66 additions & 0 deletions66
src/ipc/handlers/smart_context_handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import log from "electron-log";
import { createLoggedHandler } from "./safe_handle";
import {
appendSnippets,
readMeta,
retrieveContext,
updateRollingSummary,
rebuildIndex,
type SmartContextSnippet,
type SmartContextMeta,
} from "../utils/smart_context_store";
const logger = log.scope("smart_context_handlers");
const handle = createLoggedHandler(logger);
export interface UpsertSnippetsParams {
chatId: number;
snippets: Array<{
text: string;
source:
| { type: "message"; messageIndex?: number }
| { type: "code"; filePath: string }
| { type: "attachment"; name: string; mime?: string }
| { type: "other"; label?: string };
}>;
}
export interface RetrieveContextParams {
chatId: number;
query: string;
budgetTokens: number;
}
export function registerSmartContextHandlers() {
handle("sc:get-meta", async (_event, chatId: number): Promise<SmartContextMeta> => {
return readMeta(chatId);
});
handle(
"sc:upsert-snippets",
async (_event, params: UpsertSnippetsParams): Promise<number> => {
const count = await appendSnippets(params.chatId, params.snippets);
return count;
},
);
handle(
"sc:update-rolling-summary",
async (_event, params: { chatId: number; summary: string }): Promise<SmartContextMeta> => {
return updateRollingSummary(params.chatId, params.summary);
},
);
handle(
"sc:retrieve-context",
async (_event, params: RetrieveContextParams) => {
return retrieveContext(params.chatId, params.query, params.budgetTokens);
},
);
handle("sc:rebuild-index", async (_event, chatId: number) => {
await rebuildIndex(chatId);
return { ok: true } as const;
});
}
38 changes: 38 additions & 0 deletions38
src/ipc/ipc_client.ts
Original file line number Diff line number Diff line change
@@ -65,6 +65,12 @@ import type {
import type { Template } from "../shared/templates";
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast";
import type {
SmartContextMeta,
SmartContextSnippet,
SmartContextRetrieveResult,
RetrieveSmartContextParams,
} from "./ipc_types";
export interface ChatStreamCallbacks {
onUpdate: (messages: Message[]) => void;
@@ -240,6 +246,38 @@ export class IpcClient {
return IpcClient.instance;
}
// --- Smart Context ---
public async getSmartContextMeta(chatId: number): Promise<SmartContextMeta> {
return this.ipcRenderer.invoke("sc:get-meta", chatId);
}
public async upsertSmartContextSnippets(
chatId: number,
snippets: Array<Pick<SmartContextSnippet, "text" | "source">>,
): Promise<number> {
return this.ipcRenderer.invoke("sc:upsert-snippets", { chatId, snippets });
}
public async updateSmartContextRollingSummary(
chatId: number,
summary: string,
): Promise<SmartContextMeta> {
return this.ipcRenderer.invoke("sc:update-rolling-summary", {
chatId,
summary,
});
}
public async retrieveSmartContext(
params: RetrieveSmartContextParams,
): Promise<SmartContextRetrieveResult> {
return this.ipcRenderer.invoke("sc:retrieve-context", params);
}
public async rebuildSmartContextIndex(chatId: number): Promise<void> {
await this.ipcRenderer.invoke("sc:rebuild-index", chatId);
}
public async restartDyad(): Promise<void> {
await this.ipcRenderer.invoke("restart-dyad");
}
2 changes: 2 additions & 0 deletions2
src/ipc/ipc_host.ts
Original file line number Diff line number Diff line change
@@ -30,6 +30,7 @@ import { registerTemplateHandlers } from "./handlers/template_handlers";
import { registerPortalHandlers } from "./handlers/portal_handlers";
import { registerPromptHandlers } from "./handlers/prompt_handlers";
import { registerHelpBotHandlers } from "./handlers/help_bot_handlers";
import { registerSmartContextHandlers } from "./handlers/smart_context_handlers";
export function registerIpcHandlers() {
// Register all IPC handlers by category
@@ -65,4 +66,5 @@ export function registerIpcHandlers() {
registerPortalHandlers();
registerPromptHandlers();
registerHelpBotHandlers();
registerSmartContextHandlers();
}
40 changes: 40 additions & 0 deletions40
src/ipc/ipc_types.ts
Original file line number Diff line number Diff line change
@@ -354,6 +354,46 @@ export interface UploadFileToCodebaseResult {
filePath: string;
}
// --- Smart Context Types ---
export type SmartContextSource =
| { type: "message"; messageIndex?: number }
| { type: "code"; filePath: string }
| { type: "attachment"; name: string; mime?: string }
| { type: "other"; label?: string };
export interface SmartContextSnippet {
id: string;
text: string;
score?: number;
source: SmartContextSource;
ts: number;
tokens?: number;
}
export interface SmartContextMetaConfig {
maxSnippets?: number;
}
export interface SmartContextMeta {
entityId: string;
updatedAt: number;
rollingSummary?: string;
summaryTokens?: number;
config?: SmartContextMetaConfig;
}
export interface RetrieveSmartContextParams {
chatId: number;
query: string;
budgetTokens: number;
}
export interface SmartContextRetrieveResult {
rollingSummary?: string;
usedTokens: number;
snippets: SmartContextSnippet[];
}
// --- Prompts ---
export interface PromptDto {
id: number;
213 changes: 213 additions & 0 deletions213
src/ipc/utils/smart_context_store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { randomUUID } from "node:crypto";
import { getUserDataPath } from "../../paths/paths";
import { estimateTokens } from "./token_utils";
export type SmartContextSource =
| { type: "message"; messageIndex?: number }
| { type: "code"; filePath: string }
| { type: "attachment"; name: string; mime?: string }
| { type: "other"; label?: string };
export interface SmartContextSnippet {
id: string;
text: string;
score?: number;
source: SmartContextSource;
ts: number; // epoch ms
tokens?: number;
}
export interface SmartContextMetaConfig {
maxSnippets?: number;
}
export interface SmartContextMeta {
entityId: string; // e.g., chatId as string
updatedAt: number;
rollingSummary?: string;
summaryTokens?: number;
config?: SmartContextMetaConfig;
}
function getThreadDir(chatId: number): string {
const base = path.join(getUserDataPath(), "smart-context", "threads");
return path.join(base, String(chatId));
}
function getMetaPath(chatId: number): string {
return path.join(getThreadDir(chatId), "meta.json");
}
function getSnippetsPath(chatId: number): string {
return path.join(getThreadDir(chatId), "snippets.jsonl");
}
async function ensureDir(dir: string): Promise<void> {
await fs.mkdir(dir, { recursive: true });
}
export async function readMeta(chatId: number): Promise<SmartContextMeta> {
const dir = getThreadDir(chatId);
await ensureDir(dir);
const metaPath = getMetaPath(chatId);
try {
const raw = await fs.readFile(metaPath, "utf8");
const meta = JSON.parse(raw) as SmartContextMeta;
return meta;
} catch {
const fresh: SmartContextMeta = {
entityId: String(chatId),
updatedAt: Date.now(),
rollingSummary: "",
summaryTokens: 0,
config: { maxSnippets: 400 },
};
await fs.writeFile(metaPath, JSON.stringify(fresh, null, 2), "utf8");
return fresh;
}
}
export async function writeMeta(
chatId: number,
meta: SmartContextMeta,
): Promise<void> {
const dir = getThreadDir(chatId);
await ensureDir(dir);
const metaPath = getMetaPath(chatId);
const updated: SmartContextMeta = {
...meta,
entityId: String(chatId),
updatedAt: Date.now(),
};
await fs.writeFile(metaPath, JSON.stringify(updated, null, 2), "utf8");
}
export async function updateRollingSummary(
chatId: number,
summary: string,
): Promise<SmartContextMeta> {
const meta = await readMeta(chatId);
const summaryTokens = estimateTokens(summary || "");
const next: SmartContextMeta = {
...meta,
rollingSummary: summary,
summaryTokens,
};
await writeMeta(chatId, next);
return next;
}
export async function appendSnippets(
chatId: number,
snippets: Omit<SmartContextSnippet, "id" | "ts" | "tokens">[],
): Promise<number> {
const dir = getThreadDir(chatId);
await ensureDir(dir);
const snippetsPath = getSnippetsPath(chatId);
const withDefaults: SmartContextSnippet[] = snippets.map((s) => ({
id: randomUUID(),
ts: Date.now(),
tokens: estimateTokens(s.text),
...s,
}));
const lines = withDefaults.map((obj) => JSON.stringify(obj)).join("\n");
await fs.appendFile(snippetsPath, (lines ? lines + "\n" : ""), "utf8");
// prune if exceeded max
const meta = await readMeta(chatId);
const maxSnippets = meta.config?.maxSnippets ?? 400;
try {
const file = await fs.readFile(snippetsPath, "utf8");
const allLines = file.split("\n").filter(Boolean);
if (allLines.length > maxSnippets) {
const toKeep = allLines.slice(allLines.length - maxSnippets);
await fs.writeFile(snippetsPath, toKeep.join("\n") + "\n", "utf8");
return toKeep.length;
}
return allLines.length;
} catch {
return withDefaults.length;
}
}
export async function readAllSnippets(chatId: number): Promise<SmartContextSnippet[]> {
try {
const raw = await fs.readFile(getSnippetsPath(chatId), "utf8");
return raw
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as SmartContextSnippet);
} catch {
return [];
}
}
function normalize(value: number, min: number, max: number): number {
if (max === min) return 0;
return (value - min) / (max - min);
}
function keywordScore(text: string, query: string): number {
const toTokens = (s: string) =>
s
.toLowerCase()
.replace(/[^a-z0-9_\- ]+/g, " ")
.split(/\s+/)
.filter(Boolean);
const qTokens = new Set(toTokens(query));
const tTokens = toTokens(text);
if (qTokens.size === 0 || tTokens.length === 0) return 0;
let hits = 0;
for (const tok of tTokens) if (qTokens.has(tok)) hits++;
return hits / tTokens.length; // simple overlap ratio
}
export interface RetrieveContextResult {
rollingSummary?: string;
usedTokens: number;
snippets: SmartContextSnippet[];
}
export async function retrieveContext(
chatId: number,
query: string,
budgetTokens: number,
): Promise<RetrieveContextResult> {
const meta = await readMeta(chatId);
const snippets = await readAllSnippets(chatId);
const now = Date.now();
let minTs = now;
let maxTs = 0;
for (const s of snippets) {
if (s.ts < minTs) minTs = s.ts;
if (s.ts > maxTs) maxTs = s.ts;
}
const scored = snippets.map((s) => {
const recency = normalize(s.ts, minTs, maxTs);
const kw = keywordScore(s.text, query);
const base = 0.6 * kw + 0.4 * recency;
const score = base;
return { ...s, score } as SmartContextSnippet;
});
scored.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
const picked: SmartContextSnippet[] = [];
let usedTokens = 0;
for (const s of scored) {
const t = s.tokens ?? estimateTokens(s.text);
if (usedTokens + t > budgetTokens) break;
picked.push(s);
usedTokens += t;
}
const rollingSummary = meta.rollingSummary || "";
return { rollingSummary, usedTokens, snippets: picked };
}
export async function rebuildIndex(_chatId: number): Promise<void> {
// Placeholder for future embedding/vector index rebuild.
return;
}
6 changes: 6 additions & 0 deletions6
src/preload.ts
Original file line number Diff line number Diff line change
@@ -106,6 +106,12 @@ const validInvokeChannels = [
"restart-dyad",
"get-templates",
"portal:migrate-create",
// Smart Context
"sc:get-meta",
"sc:upsert-snippets",
"sc:update-rolling-summary",
"sc:retrieve-context",
"sc:rebuild-index",
// Help bot
"help:chat:start",
"help:chat:cancel",
24 changes: 0 additions & 24 deletions24
testing/fake-llm-server/README.md
Original file line number Diff line number Diff line change
@@ -7,7 +7,6 @@ A simple server that mimics the OpenAI streaming chat completions API for testin
- Implements a basic version of the OpenAI chat completions API
- Supports both streaming and non-streaming responses
- Always responds with "hello world" message
- Simulates a 429 rate limit error when the last message is "[429]"
- Configurable through environment variables
## Installation
@@ -83,29 +82,6 @@ For non-streaming requests, you'll get a standard JSON response:
For streaming requests, you'll receive a series of server-sent events (SSE), each containing a chunk of the response.
### Simulating Rate Limit Errors
To test how your application handles rate limiting, send a message with content exactly equal to `[429]`:
```json
{
"messages": [{ "role": "user", "content": "[429]" }],
"model": "any-model"
}
```
This will return a 429 status code with the following response:
```json
{
"error": {
"message": "Too many requests. Please try again later.",
"type": "rate_limit_error",
"param": null,
"code": "rate_limit_exceeded"
}
}
```
## Configuration
12 changes: 1 addition & 11 deletions12
testing/fake-llm-server/chatCompletionHandler.ts
Original file line number Diff line number Diff line change
@@ -10,18 +10,8 @@ export const createChatCompletionHandler =
const { stream = false, messages = [] } = req.body;
console.log("* Received messages", messages);
// Check if the last message contains "[429]" to simulate rate limiting
// Disabled rate limit simulation
const lastMessage = messages[messages.length - 1];
if (lastMessage && lastMessage.content === "[429]") {
return res.status(429).json({
error: {
message: "Too many requests. Please try again later.",
type: "rate_limit_error",
param: null,
code: "rate_limit_exceeded",
},
});
}
let messageContent = CANNED_MESSAGE;
Footer