From f2566f9b86322a0f26bea3b63c41b70343e29deb Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Mon, 11 May 2026 11:30:02 -0400 Subject: [PATCH] integrate docs/archive/* for reference until no longer needed --- .gitignore | 2 +- docs/archive/models/ai-provider.ts | 124 ++ docs/archive/models/ai-skill.ts | 79 + docs/archive/models/chat-history.ts | 158 ++ docs/archive/models/chat-session.ts | 80 + docs/archive/models/csrf-token.ts | 31 + docs/archive/models/drone-work-order.ts | 59 + docs/archive/models/drone.ts | 43 + docs/archive/models/host-monitor.ts | 68 + docs/archive/models/project.ts | 52 + docs/archive/models/session.ts | 58 + docs/archive/models/socket-message.ts | 47 + docs/archive/models/user.ts | 165 ++ .../services/__tests__/chat.service.test.ts | 119 ++ docs/archive/services/agent.ts | 1500 +++++++++++++++++ docs/archive/services/ai.ts | 307 ++++ docs/archive/services/auth.ts | 457 +++++ docs/archive/services/chat-session.ts | 136 ++ docs/archive/services/chat.service.ts | 370 ++++ docs/archive/services/csrf-token.ts | 127 ++ docs/archive/services/host-monitor.ts | 173 ++ docs/archive/services/project.ts | 138 ++ docs/archive/services/search.ts | 325 ++++ docs/archive/services/session.ts | 201 +++ docs/archive/services/user.ts | 188 +++ docs/archive/services/vector-store.ts | 206 +++ docs/archive/services/web-fetcher.ts | 276 +++ docs/archive/tools/chat/export.ts | 553 ++++++ docs/archive/tools/chat/history.ts | 182 ++ docs/archive/tools/chat/index.ts | 8 + docs/archive/tools/chat/subagent.ts | 155 ++ docs/archive/tools/chat/summarize.ts | 166 ++ docs/archive/tools/file/edit.test.ts | 287 ++++ docs/archive/tools/file/edit.ts | 439 +++++ docs/archive/tools/file/fetch-url.ts | 195 +++ docs/archive/tools/file/index.ts | 9 + docs/archive/tools/file/read.test.ts | 129 ++ docs/archive/tools/file/read.ts | 230 +++ docs/archive/tools/file/shell.test.ts | 120 ++ docs/archive/tools/file/shell.ts | 199 +++ docs/archive/tools/file/write.test.ts | 154 ++ docs/archive/tools/file/write.ts | 202 +++ docs/archive/tools/index.ts | 65 + docs/archive/tools/memory/index.ts | 6 + docs/archive/tools/memory/pin-add.ts | 124 ++ docs/archive/tools/memory/pin-remove.ts | 128 ++ docs/archive/tools/search/glob.test.ts | 61 + docs/archive/tools/search/glob.ts | 208 +++ docs/archive/tools/search/google.ts | 224 +++ docs/archive/tools/search/grep.test.ts | 83 + docs/archive/tools/search/grep.ts | 293 ++++ docs/archive/tools/search/index.ts | 20 + docs/archive/tools/search/list.test.ts | 79 + docs/archive/tools/search/list.ts | 268 +++ docs/archive/tools/setup.ts | 74 + docs/archive/tools/skills/create-skill.ts | 158 ++ docs/archive/tools/skills/get-skill.ts | 85 + docs/archive/tools/skills/index.ts | 22 + docs/archive/tools/skills/search-skills.ts | 108 ++ docs/archive/tools/skills/update-skill.ts | 170 ++ 60 files changed, 10692 insertions(+), 1 deletion(-) create mode 100644 docs/archive/models/ai-provider.ts create mode 100644 docs/archive/models/ai-skill.ts create mode 100644 docs/archive/models/chat-history.ts create mode 100644 docs/archive/models/chat-session.ts create mode 100644 docs/archive/models/csrf-token.ts create mode 100644 docs/archive/models/drone-work-order.ts create mode 100644 docs/archive/models/drone.ts create mode 100644 docs/archive/models/host-monitor.ts create mode 100644 docs/archive/models/project.ts create mode 100644 docs/archive/models/session.ts create mode 100644 docs/archive/models/socket-message.ts create mode 100644 docs/archive/models/user.ts create mode 100644 docs/archive/services/__tests__/chat.service.test.ts create mode 100644 docs/archive/services/agent.ts create mode 100644 docs/archive/services/ai.ts create mode 100644 docs/archive/services/auth.ts create mode 100644 docs/archive/services/chat-session.ts create mode 100644 docs/archive/services/chat.service.ts create mode 100644 docs/archive/services/csrf-token.ts create mode 100644 docs/archive/services/host-monitor.ts create mode 100644 docs/archive/services/project.ts create mode 100644 docs/archive/services/search.ts create mode 100644 docs/archive/services/session.ts create mode 100644 docs/archive/services/user.ts create mode 100644 docs/archive/services/vector-store.ts create mode 100644 docs/archive/services/web-fetcher.ts create mode 100644 docs/archive/tools/chat/export.ts create mode 100644 docs/archive/tools/chat/history.ts create mode 100644 docs/archive/tools/chat/index.ts create mode 100644 docs/archive/tools/chat/subagent.ts create mode 100644 docs/archive/tools/chat/summarize.ts create mode 100644 docs/archive/tools/file/edit.test.ts create mode 100644 docs/archive/tools/file/edit.ts create mode 100644 docs/archive/tools/file/fetch-url.ts create mode 100644 docs/archive/tools/file/index.ts create mode 100644 docs/archive/tools/file/read.test.ts create mode 100644 docs/archive/tools/file/read.ts create mode 100644 docs/archive/tools/file/shell.test.ts create mode 100644 docs/archive/tools/file/shell.ts create mode 100644 docs/archive/tools/file/write.test.ts create mode 100644 docs/archive/tools/file/write.ts create mode 100644 docs/archive/tools/index.ts create mode 100644 docs/archive/tools/memory/index.ts create mode 100644 docs/archive/tools/memory/pin-add.ts create mode 100644 docs/archive/tools/memory/pin-remove.ts create mode 100644 docs/archive/tools/search/glob.test.ts create mode 100644 docs/archive/tools/search/glob.ts create mode 100644 docs/archive/tools/search/google.ts create mode 100644 docs/archive/tools/search/grep.test.ts create mode 100644 docs/archive/tools/search/grep.ts create mode 100644 docs/archive/tools/search/index.ts create mode 100644 docs/archive/tools/search/list.test.ts create mode 100644 docs/archive/tools/search/list.ts create mode 100644 docs/archive/tools/setup.ts create mode 100644 docs/archive/tools/skills/create-skill.ts create mode 100644 docs/archive/tools/skills/get-skill.ts create mode 100644 docs/archive/tools/skills/index.ts create mode 100644 docs/archive/tools/skills/search-skills.ts create mode 100644 docs/archive/tools/skills/update-skill.ts diff --git a/.gitignore b/.gitignore index c448243..48f12a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ gadget*log logfetch -docs/archive +.gadget node_modules diff --git a/docs/archive/models/ai-provider.ts b/docs/archive/models/ai-provider.ts new file mode 100644 index 0000000..eb3fde4 --- /dev/null +++ b/docs/archive/models/ai-provider.ts @@ -0,0 +1,124 @@ +// src/models/ai-provider.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Schema, Document, model } from "mongoose"; + +export type ApiType = "ollama" | "openai"; + +/** + * Normalised capability flags stored with each model record. These are + * populated during model refresh from provider-specific metadata and drive + * slot-based filtering in the UI (e.g. only canCallTools models for Agent). + */ +export interface IAiModelSettings { + temperature?: number; + topP?: number; + topK?: number; + numCtx?: number; +} + +export interface IAiModelCapabilities { + /** Model supports structured function / tool calling via the API. */ + canCallTools: boolean; + /** Model accepts image inputs (multimodal / vision). */ + hasVision: boolean; + /** Model can produce vector embeddings (required for the Vector slot). */ + hasEmbedding: boolean; + /** Model has an explicit reasoning / thinking phase (e.g. o1, QwQ). */ + hasThinking: boolean; + /** Model is instruction-tuned / chat-tuned (as opposed to a base model). */ + isInstructTuned: boolean; +} + +export const AiModelCapabilitiesSchema = new Schema( + { + canCallTools: { type: Boolean, default: false }, + hasVision: { type: Boolean, default: false }, + hasEmbedding: { type: Boolean, default: false }, + hasThinking: { type: Boolean, default: false }, + isInstructTuned: { type: Boolean, default: false }, + }, + { _id: false }, +); + +export interface IAiModel { + id: string; + name: string; + /** + * Raw parameter count in billions (float). Use parameterLabel for display. + */ + parameterCount?: number; + /** + * Human-readable parameter size label sourced directly from the provider, + * e.g. "7b", "70b", "3.8b". + */ + parameterLabel?: string; + contextWindow?: number; + capabilities: IAiModelCapabilities; + settings?: IAiModelSettings; +} + +export const AiModelSettingsSchema = new Schema( + { + temperature: { type: Number }, + topP: { type: Number }, + topK: { type: Number }, + numCtx: { type: Number }, + }, + { _id: false }, +); + +export const AiModelSchema = new Schema( + { + id: { type: String, required: true }, + name: { type: String, required: true }, + parameterCount: { type: Number }, + parameterLabel: { type: String }, + contextWindow: { type: Number }, + capabilities: { + type: AiModelCapabilitiesSchema, + default: () => ({ + canCallTools: false, + hasVision: false, + hasEmbedding: false, + hasThinking: false, + isInstructTuned: false, + }), + }, + settings: { + type: AiModelSettingsSchema, + default: undefined, + }, + }, + { _id: false }, +); + +export interface IAiProvider extends Document { + name: string; + apiType: ApiType; + baseUrl: string; + apiKey: string; + enabled: boolean; + models: IAiModel[]; + lastModelRefresh: Date; +} + +export const AiProviderSchema = new Schema({ + name: { type: String, required: true }, + apiType: { type: String, enum: ["ollama", "openai"], required: true }, + baseUrl: { type: String, required: true }, + apiKey: { type: String, required: true, select: false }, + enabled: { type: Boolean, default: true, required: true }, + models: { type: [AiModelSchema], default: [], required: true }, + lastModelRefresh: { type: Date, default: Date.now }, +}); + +AiProviderSchema.index({ name: 1 }, { unique: true }); + +export const AiProvider = model("AiProvider", AiProviderSchema); +export default AiProvider; + +// Note: Index synchronization is now handled during application startup +// to ensure the database connection is established first. +// See src/lib/db.ts for the syncDatabaseIndexes function. diff --git a/docs/archive/models/ai-skill.ts b/docs/archive/models/ai-skill.ts new file mode 100644 index 0000000..b3bb05e --- /dev/null +++ b/docs/archive/models/ai-skill.ts @@ -0,0 +1,79 @@ +// src/models/ai-skill.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Types, Schema, Document, model } from "mongoose"; + +import { ChatSessionMode } from "./chat-session.js"; + +/* + * A skill is a recipe an agent can follow, or knowledge an agent will need, + * while working on tasks for Users. Skills are stored in the database and can + * be created, updated, and deleted by Users and by Agents using the skills + * tool(s). + * + * The User will have a Skills editor, which lets them maintain their library + * of skills that is unique to that User. Skills that don't have a User are + * global and accessible to all Agents in all sessions. + */ + +export interface IAiSkillHistory { + version: number; + name: string; + description: string; + tags: string[]; + modes: ChatSessionMode[]; + content: string; +} +export const AiSkillHistorySchema = new Schema({ + version: { type: Number, required: true }, + name: { type: String, required: true }, + description: { type: String, required: true }, + tags: { type: [String], default: [] }, + modes: { type: [String], enum: ChatSessionMode, default: [] }, + content: { type: String, required: true }, +}); + +export interface IAiSkill extends Document { + _id: Types.ObjectId; + createdAt: Date; + updatedAt: Date; + user: Types.ObjectId | null; // null means "global" skill (available to all users) + name: string; + description: string; + tags: string[]; + modes: ChatSessionMode[]; + content: string; + history: IAiSkillHistory[]; +} + +export const AiSkillSchema = new Schema({ + createdAt: { type: Date, default: Date.now, required: true }, + updatedAt: { type: Date, default: Date.now, required: true }, + user: { type: Types.ObjectId, null: true, index: 1, ref: "User" }, + name: { type: String, required: true }, + description: { type: String, required: true }, + tags: { type: [String], default: [] }, + modes: { type: [String], enum: ChatSessionMode, default: [] }, + content: { type: String, required: true }, + history: { type: [AiSkillHistorySchema], default: [], required: true }, +}); + +AiSkillSchema.index( + { name: "text", description: "text", tags: "text" }, + { + weights: { + name: 5, + description: 3, + tags: 1, + }, + name: "AiSkillTextIndex", + }, +); + +export const AiSkill = model("AiSkill", AiSkillSchema); +export default AiSkill; + +// Note: Index synchronization is now handled during application startup +// to ensure the database connection is established first. +// See src/lib/db.ts for the syncDatabaseIndexes function. diff --git a/docs/archive/models/chat-history.ts b/docs/archive/models/chat-history.ts new file mode 100644 index 0000000..6dc43c9 --- /dev/null +++ b/docs/archive/models/chat-history.ts @@ -0,0 +1,158 @@ +// src/models/chat-history.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Types, Schema, Document, model } from "mongoose"; + +import { IChatSession, ChatSessionMode } from "./chat-session.js"; +import { IUser } from "./user.js"; + +export enum ChatHistoryStatus { + Processing = "processing", + Success = "success", + Failed = "failed", +} +export interface IChatToolCallParameter { + name: string; + value: string; +} +export const ChatToolCallParameterSchema = new Schema( + { + name: { type: String, required: true }, + value: { type: String, required: true }, + }, + { _id: false }, +); + +export interface IChatFileOperation { + type: "read" | "write" | "edit" | "shell"; + path?: string; + diff?: string; + linesAdded?: number; + linesRemoved?: number; + isBinary?: boolean; +} + +export interface IChatToolCall { + tool: { + name: string; + callId: string; + parameters: IChatToolCallParameter[]; + }; + response: string; + fileOperation?: IChatFileOperation; + subagentStats?: { + inputTokens: number; + outputTokens: number; + toolCallCount: number; + }; +} +const ChatFileOperationSchema = new Schema( + { + type: { + type: String, + required: true, + enum: ["read", "write", "edit", "shell"], + }, + path: { type: String }, + diff: { type: String }, + linesAdded: { type: Number }, + linesRemoved: { type: Number }, + isBinary: { type: Boolean }, + }, + { _id: false }, +); + +const SubagentStatsSchema = new Schema( + { + inputTokens: { type: Number }, + outputTokens: { type: Number }, + toolCallCount: { type: Number }, + }, + { _id: false }, +); + +export const ChatToolCallSchema = new Schema({ + tool: { + name: { type: String, required: true }, + callId: { type: String, required: true }, + parameters: { + type: [ChatToolCallParameterSchema], + default: [], + required: true, + }, + }, + response: { type: String }, + fileOperation: { type: ChatFileOperationSchema }, + subagentStats: { type: SubagentStatsSchema }, +}); + +export interface IChatHistoryError { + message: string; + stack?: string; + timestamp: Date; +} + +export interface IChatHistory extends Document { + createdAt: Date; + user: IUser | Types.ObjectId; + session: IChatSession | Types.ObjectId; + prompt: string; + mode: ChatSessionMode; + status: ChatHistoryStatus; + toolCalls: IChatToolCall[]; + fileOperations: IChatFileOperation[]; + response: { + thinking?: string; + message?: string; + }; + qdrantId?: string; + isSubagent?: boolean; + error?: IChatHistoryError; + subagentHistory?: IChatHistory[]; // For sub-agents, reference to their own history entries + inputTokens: number; + outputTokens: number; +} + +export const ChatHistorySchema = new Schema({ + createdAt: { type: Date, default: Date.now, required: true }, + user: { type: Types.ObjectId, required: true, ref: "User" }, + session: { type: Types.ObjectId, required: true, ref: "ChatSession" }, + prompt: { type: String, required: true }, + mode: { type: String, enum: ChatSessionMode, required: true }, + toolCalls: { type: [ChatToolCallSchema], default: [], required: true }, + fileOperations: { + type: [ChatFileOperationSchema], + default: [], + required: true, + }, + response: { + thinking: { type: String }, + message: { type: String }, + }, + qdrantId: { type: String }, + status: { + type: String, + enum: ChatHistoryStatus, + default: ChatHistoryStatus.Processing, + }, + isSubagent: { type: Boolean, default: false }, + error: { + message: { type: String }, + stack: { type: String }, + timestamp: { type: Date }, + }, + subagentHistory: [{ type: Schema.Types.ObjectId, ref: "ChatHistory" }], + inputTokens: { type: Number, default: 0 }, + outputTokens: { type: Number, default: 0 }, +}); + +export const ChatHistory = model( + "ChatHistory", + ChatHistorySchema, +); +export default ChatHistory; + +// Note: Index synchronization is now handled during application startup +// to ensure the database connection is established first. +// See src/lib/db.ts for the syncDatabaseIndexes function. diff --git a/docs/archive/models/chat-session.ts b/docs/archive/models/chat-session.ts new file mode 100644 index 0000000..bfb3ea6 --- /dev/null +++ b/docs/archive/models/chat-session.ts @@ -0,0 +1,80 @@ +// src/models/chat-session.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Types, Schema, Document, model } from "mongoose"; + +import { IUser } from "./user.js"; +import { IProject } from "./project.js"; + +export enum ChatSessionType { + Desktop = "desktop", + Mobile = "mobile", + Extension = "extension", +} + +export enum ChatSessionMode { + Plan = "plan", // for planning and brainstorming + Build = "build", // for building and coding + Test = "test", // for testing and debugging + Ship = "ship", // for finalizing and shipping + Develop = "dev", // for working on the Gadget Code agentic harness itself +} + +export interface IChatSessionPin { + _id?: Types.ObjectId; + content: string; +} + +export interface IChatSession extends Document { + createdAt: Date; + lastMessageAt?: Date; + user: IUser | Types.ObjectId; + project: IProject | Types.ObjectId; + name: string; + type: ChatSessionType; + mode: ChatSessionMode; + stats: { + turnCount: number; + toolCallCount: number; + inputTokens: number; + outputTokens: number; + }; + pins: IChatSessionPin[]; +} +export const ChatSessionPinSchema = new Schema({ + content: { type: String, required: true }, +}); + +export const ChatSessionSchema = new Schema({ + createdAt: { type: Date, default: Date.now, required: true }, + lastMessageAt: { type: Date }, + user: { type: Types.ObjectId, required: true, index: 1, ref: "User" }, + project: { type: Types.ObjectId, required: false, index: 1, ref: "Project" }, + name: { type: String, default: "New Session", required: true }, + type: { + type: String, + enum: ChatSessionType, + default: ChatSessionType.Desktop, + required: true, + }, + mode: { + type: String, + enum: ChatSessionMode, + default: ChatSessionMode.Build, + required: true, + }, + stats: { + turnCount: { type: Number, default: 0, required: true }, + toolCallCount: { type: Number, default: 0, required: true }, + inputTokens: { type: Number, default: 0, required: true }, + outputTokens: { type: Number, default: 0, required: true }, + }, + pins: { type: [ChatSessionPinSchema], default: [], required: true }, +}); + +export const ChatSession = model( + "ChatSession", + ChatSessionSchema, +); +export default ChatSession; diff --git a/docs/archive/models/csrf-token.ts b/docs/archive/models/csrf-token.ts new file mode 100644 index 0000000..39d814c --- /dev/null +++ b/docs/archive/models/csrf-token.ts @@ -0,0 +1,31 @@ +// src/models/csrf-token.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Types, Schema, Document, model } from "mongoose"; + +export interface ICsrfToken extends Document { + _id: Types.ObjectId; + createdAt: Date; + expiresAt: Date; + claimedAt?: Date; + name: string; + token: string; + user?: Types.ObjectId; + ip?: string; +} + +const CsrfTokenSchema = new Schema({ + createdAt: { type: Date, required: true }, + expiresAt: { type: Date, required: true, index: 1 }, + claimedAt: { type: Date }, + name: { type: String, required: true, index: 1 }, + token: { type: String, required: true, unique: true }, + user: { type: Schema.Types.ObjectId, ref: "User", index: 1 }, + ip: { type: String }, +}); + +CsrfTokenSchema.index({ name: 1, token: 1 }); + +export const CsrfToken = model("CsrfToken", CsrfTokenSchema); +export default CsrfToken; diff --git a/docs/archive/models/drone-work-order.ts b/docs/archive/models/drone-work-order.ts new file mode 100644 index 0000000..e8ad91b --- /dev/null +++ b/docs/archive/models/drone-work-order.ts @@ -0,0 +1,59 @@ +// src/models/drone-work-order.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Types, Schema, Document, model } from "mongoose"; + +import { IUser } from "./user.js"; +import { IDrone } from "./drone.js"; + +export enum DroneWorkOrderStatus { + Pending = "pending", + InProgress = "in_progress", + Completed = "completed", + Failed = "failed", +} + +export interface IDroneWorkOrder extends Document { + _id: Types.ObjectId; + createdAt: Date; + finishedAt: Date; + user: IUser | Types.ObjectId; + drone: IDrone | Types.ObjectId; + title: string; + instructions: string[]; + notes: string[]; + responseFormat: string; + response?: string; + status: DroneWorkOrderStatus; + errorMessages: string[]; +} + +const DroneWorkOrderSchema = new Schema({ + createdAt: { type: Date, required: true }, + finishedAt: { type: Date }, + user: { type: Schema.Types.ObjectId, ref: "User", index: 1 }, + drone: { type: Schema.Types.ObjectId, ref: "Drone", index: 1 }, + title: { type: String, required: true }, + instructions: { type: [String], required: true }, + notes: { type: [String], required: false }, + responseFormat: { type: String, required: true }, + status: { + type: String, + enum: DroneWorkOrderStatus, + default: DroneWorkOrderStatus.Pending, + required: true, + }, + response: { type: String }, + errorMessages: { type: [String], default: [], required: true }, +}); + +export const DroneWorkOrder = model( + "DroneWorkOrder", + DroneWorkOrderSchema, +); +export default DroneWorkOrder; + +// Note: Index synchronization is now handled during application startup +// to ensure the database connection is established first. +// See src/lib/db.ts for the syncDatabaseIndexes function. diff --git a/docs/archive/models/drone.ts b/docs/archive/models/drone.ts new file mode 100644 index 0000000..4beed1c --- /dev/null +++ b/docs/archive/models/drone.ts @@ -0,0 +1,43 @@ +// src/models/drone.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Types, Schema, Document, model } from "mongoose"; + +export enum DroneStatus { + Online = "online", + Offline = "offline", +} + +export interface IDrone extends Document { + _id: Types.ObjectId; + createdAt: Date; + updatedAt: Date; + user: Types.ObjectId; + ip: string; + port: number; + status: DroneStatus; +} + +const DroneSchema = new Schema({ + createdAt: { type: Date, required: true }, + updatedAt: { type: Date, required: true }, + user: { type: Schema.Types.ObjectId, ref: "User", index: 1 }, + status: { + type: String, + enum: DroneStatus, + default: DroneStatus.Online, + required: true, + }, + ip: { type: String, required: true }, + port: { type: Number, required: true }, +}); + +DroneSchema.index({ user: 1, project: 1, status: 1 }); + +export const Drone = model("Drone", DroneSchema); +export default Drone; + +// Note: Index synchronization is now handled during application startup +// to ensure the database connection is established first. +// See src/lib/db.ts for the syncDatabaseIndexes function. diff --git a/docs/archive/models/host-monitor.ts b/docs/archive/models/host-monitor.ts new file mode 100644 index 0000000..a94cd03 --- /dev/null +++ b/docs/archive/models/host-monitor.ts @@ -0,0 +1,68 @@ +// src/models/host-monitor.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Types, Schema, Document, model } from "mongoose"; + +export interface IMemoryMonitor { + count: number; + bytes: number; +} + +export const MemoryMonitorSchema = new Schema({ + count: { type: Number, default: 0, required: true }, + bytes: { type: Number, default: 0, required: true }, +}); + +export interface IHostMonitor extends Document { + _id: Types.ObjectId; + hostname: string; + timestamp: Date; + memory: { + rss: number; + v8: { + heapTotal: number; + heapUsed: number; + heapExternal: number; + }; + os: { + total: number; + free: number; + }; + ai: { + subagents: IMemoryMonitor; + fileOperations: IMemoryMonitor; + toolCalls: IMemoryMonitor; + }; + logs: IMemoryMonitor; + }; +} + +export const HostMonitorSchema = new Schema({ + hostname: { type: String, required: true, index: 1 }, + timestamp: { type: Date, required: true, index: -1 }, + memory: { + rss: { type: Number, required: true }, + v8: { + heapTotal: { type: Number, required: true }, + heapUsed: { type: Number, required: true }, + heapExternal: { type: Number, required: true, default: 0 }, + }, + os: { + total: { type: Number, required: true }, + free: { type: Number, required: true }, + }, + ai: { + subagents: { type: MemoryMonitorSchema, required: true }, + fileOperations: { type: MemoryMonitorSchema, required: true }, + toolCalls: { type: MemoryMonitorSchema, required: true }, + }, + logs: { type: MemoryMonitorSchema, required: true }, + }, +}); + +export const HostMonitor = model( + "HostMonitor", + HostMonitorSchema, +); +export default HostMonitor; diff --git a/docs/archive/models/project.ts b/docs/archive/models/project.ts new file mode 100644 index 0000000..479719c --- /dev/null +++ b/docs/archive/models/project.ts @@ -0,0 +1,52 @@ +// src/models/project.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Types, Schema, Document, model } from "mongoose"; +import { IUser } from "./user.js"; + +export interface IProject extends Document { + createdAt: Date; + user: IUser | Types.ObjectId; + name: string; + slug: string; + gitUrl?: string; +} + +export const ProjectSchema = new Schema({ + createdAt: { type: Date, default: Date.now, required: true }, + user: { type: Types.ObjectId, required: true, index: 1, ref: "User" }, + name: { type: String, default: "New Project", required: true }, + slug: { type: String, default: "new-project", required: true }, + gitUrl: { type: String }, +}); + +ProjectSchema.index( + { + user: 1, + slug: 1, + }, + { + partialFilterExpression: { + slug: { $exists: true }, + }, + unique: true, + }, +); + +ProjectSchema.index( + { + user: 1, + gitUrl: 1, + }, + { + partialFilterExpression: { + gitUrl: { $exists: true }, + }, + unique: true, + }, +); + +export const Project = model("Project", ProjectSchema); + +export default Project; diff --git a/docs/archive/models/session.ts b/docs/archive/models/session.ts new file mode 100644 index 0000000..007d6a4 --- /dev/null +++ b/docs/archive/models/session.ts @@ -0,0 +1,58 @@ +// src/models/session.ts +// Copyright (C) 2026 DTP Technologies, LLC +// All Rights Reserved + +import { Types, Schema, Document, model } from "mongoose"; + +export interface ISession extends Document { + _id: Types.ObjectId; + userId: Types.ObjectId; + token: string; + createdAt: Date; + expiresAt: Date; + lastActivityAt: Date; + ipAddress?: string; + userAgent?: string; +} + +const SessionSchema = new Schema({ + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + token: { + type: String, + required: true, + unique: true, + index: true, + }, + createdAt: { + type: Date, + required: true, + default: Date.now, + }, + expiresAt: { + type: Date, + required: true, + index: true, + }, + lastActivityAt: { + type: Date, + required: true, + default: Date.now, + }, + ipAddress: { + type: String, + }, + userAgent: { + type: String, + }, +}); + +// Index for cleanup queries +SessionSchema.index({ expiresAt: 1 }); + +export const Session = model("Session", SessionSchema); +export default Session; diff --git a/docs/archive/models/socket-message.ts b/docs/archive/models/socket-message.ts new file mode 100644 index 0000000..8b4e53e --- /dev/null +++ b/docs/archive/models/socket-message.ts @@ -0,0 +1,47 @@ +// src/models/socket-message.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Schema, model, Types, Document } from "mongoose"; + +export interface ISocketMessage extends Document { + _id: Types.ObjectId; + userId: Types.ObjectId; + event: string; + data: any; + createdAt: Date; +} + +export const SocketMessageSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + event: { + type: String, + required: true, + }, + data: { + type: Schema.Types.Mixed, + required: true, + }, + }, + { + timestamps: { createdAt: true, updatedAt: false }, + }, +); + +// TTL index to auto-expire messages after 24 hours +SocketMessageSchema.index({ createdAt: 1 }, { expireAfterSeconds: 86400 }); + +// Compound index for efficient querying by user and creation time +SocketMessageSchema.index({ userId: 1, createdAt: 1 }); + +export const SocketMessage = model( + "SocketMessage", + SocketMessageSchema, +); +export default SocketMessage; diff --git a/docs/archive/models/user.ts b/docs/archive/models/user.ts new file mode 100644 index 0000000..46dd10c --- /dev/null +++ b/docs/archive/models/user.ts @@ -0,0 +1,165 @@ +// src/models/user.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Types, Schema, Document, model } from "mongoose"; + +export interface IUserFlags { + isAdmin: boolean; + isTest: boolean; + isBanned: boolean; +} +export const UserFlagsSchema = new Schema( + { + isAdmin: { type: Boolean, default: false, required: true }, + isTest: { type: Boolean, default: false, required: true }, + isBanned: { type: Boolean, default: false, required: true }, + }, + { _id: false }, +); + +export interface IAiConfig { + providerIds: Types.ObjectId[]; + agentProviderId: Types.ObjectId | null; + agentModel: string; + vectorProviderId: Types.ObjectId | null; + vectorModel: string; + utilityProviderId: Types.ObjectId | null; + utilityModel: string; +} + +export interface IGabConnection { + social: { + apiToken: string; + }; + ai: { + apiToken: string; + }; +} + +export interface IGoogleCseConnection { + apiKey: string; + engineId: string; +} + +export interface IUserServiceConnections { + gab: IGabConnection; + ai: IAiConfig; + google?: { + cse?: IGoogleCseConnection; + }; +} + +export interface IUser extends Document { + _id: Types.ObjectId; + username: string; + username_lc: string; + passwordSalt?: string; + password?: string; + displayName: string; + flags: IUserFlags; + connections: IUserServiceConnections; + projectsDirectory?: string; +} +export const AiConfigSchema = new Schema( + { + providerIds: { + type: [Schema.Types.ObjectId], + ref: "AiProvider", + default: [], + }, + agentProviderId: { + type: Schema.Types.ObjectId, + ref: "AiProvider", + default: null, + }, + agentModel: { type: String, default: "" }, + vectorProviderId: { + type: Schema.Types.ObjectId, + ref: "AiProvider", + default: null, + }, + vectorModel: { type: String, default: "" }, + utilityProviderId: { + type: Schema.Types.ObjectId, + ref: "AiProvider", + default: null, + }, + utilityModel: { type: String, default: "" }, + }, + { _id: false }, +); + +const GabConnectionSchema = new Schema( + { + social: { + apiToken: { type: String, select: false }, + }, + ai: { + apiToken: { type: String, select: false }, + }, + }, + { _id: false }, +); + +const GoogleCseConnectionSchema = new Schema( + { + apiKey: { type: String, select: false }, + engineId: { type: String, select: false }, + }, + { _id: false }, +); + +const UserConnectionsSchema = new Schema( + { + gab: { type: GabConnectionSchema, default: {} }, + ai: { type: AiConfigSchema, default: {} }, + google: { + cse: { type: GoogleCseConnectionSchema, default: null }, + }, + }, + { _id: false }, +); + +export const UserSchema = new Schema({ + username: { + type: String, + required: true, + minlength: 3, + maxlength: 12, + }, + username_lc: { + type: String, + required: true, + lowercase: true, + unique: true, + minlength: 3, + maxlength: 12, + }, + passwordSalt: { type: String, required: true, select: false }, + password: { type: String, required: true, select: false }, + displayName: { type: String, minlength: 3, maxlength: 30, required: true }, + flags: { type: UserFlagsSchema, required: true }, + connections: { type: UserConnectionsSchema, default: {} }, + projectsDirectory: { type: String, default: null }, +}); + +UserSchema.index( + { + username: "text", + displayName: "text", + }, + { + weights: { + username: 10, + displayName: 5, + }, + }, +); + +export const User = model("User", UserSchema); +export default User; + +// Note: Index synchronization is now handled during application startup +// to ensure the database connection is established first. +// See src/lib/db.ts for the syncDatabaseIndexes function. diff --git a/docs/archive/services/__tests__/chat.service.test.ts b/docs/archive/services/__tests__/chat.service.test.ts new file mode 100644 index 0000000..43ee743 --- /dev/null +++ b/docs/archive/services/__tests__/chat.service.test.ts @@ -0,0 +1,119 @@ +// src/services/__tests__/chat.service.test.ts +// Copyright (C) 2026 DTP Technologies, LLC +// All Rights Reserved + +import { describe, it, expect } from "vitest"; + +describe("ChatService Types", () => { + describe("ChatSessionMode enum values", () => { + it("should have Plan mode", () => { + const mode = "plan"; + expect(mode).toBe("plan"); + }); + + it("should have Build mode", () => { + const mode = "build"; + expect(mode).toBe("build"); + }); + + it("should have Test mode", () => { + const mode = "test"; + expect(mode).toBe("test"); + }); + + it("should have Ship mode", () => { + const mode = "ship"; + expect(mode).toBe("ship"); + }); + + it("should have Develop mode", () => { + const mode = "dev"; + expect(mode).toBe("dev"); + }); + }); + + describe("ChatSessionType enum values", () => { + it("should have Desktop type", () => { + const type = "desktop"; + expect(type).toBe("desktop"); + }); + + it("should have Mobile type", () => { + const type = "mobile"; + expect(type).toBe("mobile"); + }); + + it("should have Extension type", () => { + const type = "extension"; + expect(type).toBe("extension"); + }); + }); + + describe("IChatSessionListItem interface", () => { + const session = { + _id: "507f1f77bcf86cd799439011", + name: "Test Session", + lastMessageAt: new Date(), + turnCount: 5, + toolCallCount: 10, + inputTokens: 1000, + outputTokens: 2000, + createdAt: new Date(), + mode: "build", + }; + + it("should have _id", () => { + expect(session._id).toBeDefined(); + }); + + it("should have name", () => { + expect(session.name).toBe("Test Session"); + }); + + it("should have turnCount", () => { + expect(session.turnCount).toBe(5); + }); + + it("should have inputTokens", () => { + expect(session.inputTokens).toBe(1000); + }); + + it("should have outputTokens", () => { + expect(session.outputTokens).toBe(2000); + }); + }); + + describe("IChatSessionDetail extended interface", () => { + const detail = { + _id: "507f1f77bcf86cd799439011", + name: "Test Session", + lastMessageAt: new Date(), + turnCount: 5, + toolCallCount: 10, + inputTokens: 1000, + outputTokens: 2000, + createdAt: new Date(), + mode: "build", + user: "507f1f77bcf86cd799439012", + project: "My Project", + type: "desktop" as const, + pins: [{ content: "pin1" }], + }; + + it("should have user field for ownership", () => { + expect(detail.user).toBeDefined(); + }); + + it("should have project field", () => { + expect(detail.project).toBe("My Project"); + }); + + it("should have type field", () => { + expect(detail.type).toBe("desktop"); + }); + + it("should have pins array", () => { + expect(Array.isArray(detail.pins)).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/docs/archive/services/agent.ts b/docs/archive/services/agent.ts new file mode 100644 index 0000000..e2f90db --- /dev/null +++ b/docs/archive/services/agent.ts @@ -0,0 +1,1500 @@ +// src/services/agent.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import env from "../config/env.js"; + +import path from "node:path"; +import fs from "node:fs/promises"; +import { EventEmitter } from "node:events"; + +import { IUser } from "../models/user.js"; +import { IChatSession, ChatSession } from "../models/chat-session.js"; +import { ChatHistory, ChatHistoryStatus } from "../models/chat-history.js"; + +import { DtpService } from "../lib/service.js"; + +import { + getToolByName, + getToolsBySlugs, + getToolsExcludingCategory, + getToolsByMode, +} from "../tools/index.js"; +import { DtpTool } from "../lib/tool.js"; +import { ChatSessionType } from "../models/chat-session.js"; + +import { processImageForLlm } from "../lib/image-utils.js"; +import AiService from "./ai.js"; +import type { + ChatMessage, + ToolDefinition, + ToolCall, + ChatChunk, +} from "../lib/ai-client.js"; + +interface ChatContext { + messages: ChatMessage[]; + tools: ToolDefinition[]; +} + +class AgentService extends DtpService { + private events = new EventEmitter(); + private pendingUserMessages: Array<{ content: string; displayName: string }> = + []; + private abortController: AbortController | null = null; + + on(event: string, listener: (...args: any[]) => void): this { + this.events.on(event, listener); + return this; + } + + off(event: string, listener: (...args: any[]) => void): this { + this.events.off(event, listener); + return this; + } + + private emit(event: string, ...args: any[]): boolean { + return this.events.emit(event, ...args); + } + + /** + * Queue a user message to be injected into the conversation during processing. + * The message will be sent at the next opportunity (after tool results). + */ + queueUserMessage(content: string, displayName: string): void { + this.pendingUserMessages.push({ content, displayName }); + this.log.info("User message queued", { + displayName, + contentLength: content.length, + totalQueued: this.pendingUserMessages.length, + }); + } + + /** + * Clear any pending user messages (e.g., when aborting). + */ + clearPendingUserMessages(): void { + this.pendingUserMessages = []; + } + + /** + * Get all pending user messages and clear the queue. + * Returns messages to be injected into the conversation. + */ + getPendingUserMessages(): Array<{ content: string; displayName: string }> { + const messages = [...this.pendingUserMessages]; + this.pendingUserMessages = []; + return messages; + } + + /** + * Check if there are any pending user messages. + */ + hasPendingMessages(): boolean { + return this.pendingUserMessages.length > 0; + } + + /** + * Abort the current agent operation. + * Sets the abort signal to stop the streaming chat loop. + */ + abort(): void { + if (this.abortController) { + this.abortController.abort(); + this.log.info("Agent operation aborted"); + } + this.clearPendingUserMessages(); + } + + /** + * Set the abort controller for the current operation. + * Called at the start of streamChat to enable abort functionality. + */ + private setAbortController(controller: AbortController): void { + this.abortController = controller; + } + + /** + * Clear the abort controller after operation completes. + */ + private clearAbortController(): void { + this.abortController = null; + } + + get name(): string { + return "AgentService"; + } + + get slug(): string { + return "agent"; + } + + async start(): Promise { + this.log.info("service started"); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + async chat(sessionId: string, prompt: string): Promise { + const session = await ChatSession.findById(sessionId).populate("user"); + if (!session) { + throw new Error("Session not found"); + } + + const user = session.user as IUser; + const userId = user._id.toString(); + const sessionObjId = session._id as any; + + const agentModel = await AiService.getAgentModel(userId); + if (!agentModel) { + throw new Error("No agent model configured"); + } + + // Create ChatHistory record at turn start with "processing" status + const chatHistory = new ChatHistory({ + user: user._id, + session: session._id, + prompt, + mode: session.mode, + toolCalls: [], + fileOperations: [], + response: { thinking: "", message: "" }, + status: ChatHistoryStatus.Processing, + isSubagent: false, + inputTokens: 0, + outputTokens: 0, + }); + const savedHistory = await chatHistory.save(); + const historyId = savedHistory._id.toString(); + + const context = await this.buildAgentContext(session, prompt); + + let fullResponse = ""; + let fullThinking = ""; + let collectedToolCalls: any[] = []; + let subagentHistoryIds: string[] = []; + let inputTokens = 0; + let outputTokens = 0; + + try { + const result = await this.streamChat( + session, + context.messages, + context.tools, + agentModel, + userId, + historyId, + ); + fullResponse = result.response; + fullThinking = result.thinking; + collectedToolCalls = result.collectedToolCalls; + subagentHistoryIds = result.subagentHistoryIds; + inputTokens = result.inputTokens; + outputTokens = result.outputTokens; + + // Finalize ChatHistory with success status + await ChatHistory.findByIdAndUpdate(historyId, { + status: ChatHistoryStatus.Success, + "response.thinking": fullThinking, + "response.message": fullResponse, + toolCalls: (collectedToolCalls ?? []).map((tc) => ({ + tool: { + name: tc.name, + callId: tc.callId, + parameters: tc.parameters, + }, + response: tc.response, + fileOperation: tc.fileOperation ?? undefined, + subagentStats: tc.subagentStats ?? undefined, + })), + fileOperations: (collectedToolCalls ?? []) + .filter((tc) => tc.fileOperation != null) + .map((tc) => tc.fileOperation), + inputTokens, + outputTokens, + subagentHistory: subagentHistoryIds || [], + }); + + await ChatSession.findByIdAndUpdate(sessionObjId, { + $inc: { + "stats.turnCount": 1, + "stats.inputTokens": inputTokens, + "stats.outputTokens": outputTokens, + }, + lastMessageAt: new Date(), + }); + + if (session.stats.turnCount === 0) { + await this.autoNameSession(session, prompt, fullResponse); + } + } catch (error) { + this.log.error("Chat error", { error, sessionId, prompt }); + + // Finalize ChatHistory with failed status + await ChatHistory.findByIdAndUpdate(historyId, { + status: ChatHistoryStatus.Failed, + error: { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date(), + }, + }); + + await ChatSession.findByIdAndUpdate(sessionObjId, { + $inc: { + "stats.turnCount": 1, + "stats.inputTokens": 0, + "stats.outputTokens": 0, + }, + lastMessageAt: new Date(), + }); + + throw error; + } + } + + async spawnSubagent( + session: IChatSession, + agentType: "explore" | "general", + prompt: string, + ): Promise<{ + response: string; + history: Awaited>; + stats: { inputTokens: number; outputTokens: number; toolCallCount: number }; + historyIds: string[]; + }> { + const user = session.user as IUser; + const userId = user._id.toString(); + const sessionObjId = session._id as any; + + const agentModel = await AiService.getAgentModel(userId); + if (!agentModel) { + throw new Error("No agent model configured"); + } + + const context = await this.buildSubagentContext(session, agentType, prompt); + + try { + const { + response: fullResponse, + stats, + historyIds, + } = await this.streamSubagentChat( + session, + context.messages, + context.tools, + agentModel, + userId, + ); + + const history = await ChatHistory.find({ + session: sessionObjId, + }) + .sort({ createdAt: -1 }) + .lean(); + + await ChatSession.findByIdAndUpdate(sessionObjId, { + lastMessageAt: new Date(), + }); + + return { response: fullResponse, history, stats, historyIds }; + } catch (error) { + this.log.error("Subagent error", { + error, + agentType, + prompt, + }); + + await this.saveChatHistory( + session, + prompt, + undefined, + undefined, + error instanceof Error + ? { message: error.message, stack: error.stack } + : { message: String(error) }, + ); + + throw error; + } + } + + private async buildSubagentContext( + session: IChatSession, + agentType: "explore" | "general", + currentPrompt: string, + ): Promise { + const messages: ChatMessage[] = []; + + const systemContent = await this.buildSubagentSystemPrompt( + session, + agentType, + ); + messages.push({ role: "system", content: systemContent }); + + await fs.writeFile( + path.join( + env.installRoot, + "logs", + `subagent.system.${agentType}.${session._id}.md`, + ), + systemContent, + "utf-8", + ); + + messages.push({ role: "user", content: currentPrompt }); + + const modeFilteredTools = getToolsByMode(session.mode); + const subagentTools = getToolsExcludingCategory("browser") + .filter((t) => modeFilteredTools.includes(t)) + .filter((t) => t.slug !== "subagent") + .map((t: any) => t.definition); + + this.log.debug("tools available to subagent", { + toolCount: subagentTools.length, + toolNames: subagentTools.map((t: any) => t.function.name), + }); + + return { messages, tools: subagentTools }; + } + + private async buildSubagentSystemPrompt( + session: IChatSession, + agentType: "explore" | "general", + ): Promise { + const user = session.user as IUser; + const promptFilePath = path.join( + env.installRoot, + "data", + "prompts", + "subagent", + agentType, + "system.md", + ); + + let prompt = await fs.readFile(promptFilePath, "utf-8"); + prompt += + "\n\n## SESSION INFORMATION\n\n- Session ID: " + + session._id + + "\n- Session Type: " + + session.type + + "\n- Created At: " + + session.createdAt.toISOString(); + prompt += + "\n\n## USER INFORMATION\n\n- User ID: " + + user._id + + "\n- Username: " + + user.username + + "\n- Display Name: " + + user.displayName; + prompt += + "\n\n## SYSTEM INFORMATION\n\n- Current Time: " + + new Date().toISOString() + + "\n- Gadget Version: " + + env.pkg.version + + "\n- Timezone: " + + env.timezone; + + try { + const agentConfig = await fs.readFile("AGENTS.md", "utf-8"); + this.log.debug("integrating AGENTS.md into subagent system prompt", { + subagent: agentType, + }); + prompt += "\n\n## AGENT CONFIGURATION\n\n" + agentConfig; + } catch (error) { + this.log.debug("AGENTS.md file not found or failed to load.", { + subagent: agentType, + error: error instanceof Error ? error.message : String(error), + }); + } + + return this.renderSystemPromptTemplate(prompt, session.mode); + } + + private async streamSubagentChat( + session: IChatSession, + messages: ChatMessage[], + tools: ToolDefinition[], + llm: string | undefined | null, + userId: string, + ): Promise<{ + response: string; + thinking: string; + stats: { inputTokens: number; outputTokens: number; toolCallCount: number }; + historyIds: string[]; + }> { + let fullResponse = ""; + let fullThinking = ""; + let inputTokens = 0; + let outputTokens = 0; + let toolCallCount = 0; + const historyIds: string[] = []; + + if (!llm) { + throw new Error("No LLM model configured for this subagent"); + } + + const client = await AiService.getAgentClient(userId); + client.on("finish_reason", (reason) => { + this.emit("finish_reason", { reason }); + }); + + let chatOptions; + try { + const providerInfo = await AiService.getAgentProviderInfo(userId); + if (providerInfo) { + chatOptions = await AiService.getModelChatOptions( + userId, + providerInfo.providerId, + providerInfo.model, + ); + } + } catch (err) { + this.log.warn("Failed to get model settings, using defaults", { + error: err, + }); + } + + let currentMessages: ChatMessage[] = [...messages]; + let continueLoop = true; + let lastFinishReason: string | undefined; + let iterations = 0; + + while (continueLoop) { + iterations++; + continueLoop = false; + + let iterationThinking = ""; + let iterationResponse = ""; + const toolCallMap = new Map(); + const collectedToolCalls: Array<{ + name: string; + callId: string; + parameters: Array<{ name: string; value: string }>; + response: string; + fileOperation?: unknown; + subagentStats?: unknown; + }> = []; + + this.log.debug("Starting subagent chat request", { + iteration: iterations, + messageCount: currentMessages.length, + hasTools: tools.length > 0, + model: llm, + }); + + let stream: AsyncGenerator; + try { + stream = await client.streamChat( + currentMessages, + tools, + llm, + chatOptions, + ); + } catch (chatError) { + this.log.error("Failed to create subagent stream", { + error: + chatError instanceof Error ? chatError.message : String(chatError), + model: llm, + }); + throw chatError; + } + + for await (const chunk of stream) { + if (chunk.thinking) { + iterationThinking += chunk.thinking; + fullThinking += chunk.thinking; + } + + if (chunk.content) { + iterationResponse += chunk.content; + fullResponse += chunk.content; + } + + if (chunk.done && chunk.finish_reason) { + lastFinishReason = chunk.finish_reason; + } + + if (chunk.toolCall) { + const tcDelta = chunk.toolCall; + const index = tcDelta.index ?? 0; + let existing = toolCallMap.get(index); + + if (!existing) { + existing = { + id: + tcDelta.id ?? "call-" + Math.random().toString(36).slice(2, 9), + type: "function", + function: { + name: tcDelta.function?.name ?? "", + arguments: "", + }, + }; + toolCallMap.set(index, existing); + } else { + if (tcDelta.id) existing.id = tcDelta.id; + if (tcDelta.function?.name) + existing.function.name = tcDelta.function.name; + } + + if (tcDelta.function?.arguments !== undefined) { + const deltaArgs = + typeof tcDelta.function.arguments === "string" + ? tcDelta.function.arguments + : JSON.stringify(tcDelta.function.arguments); + + const currentArgs = existing.function.arguments; + const trimmedDelta = deltaArgs.trim(); + const isFullObject = + trimmedDelta.startsWith("{") && trimmedDelta.endsWith("}"); + + if ( + isFullObject && + (currentArgs.length === 0 || currentArgs === deltaArgs) + ) { + existing.function.arguments = deltaArgs; + } else { + existing.function.arguments += deltaArgs; + } + } + } + } + + const finalToolCalls = Array.from(toolCallMap.values()); + + currentMessages.push({ + role: "assistant", + content: iterationResponse, + tool_calls: finalToolCalls.length > 0 ? finalToolCalls : undefined, + }); + + if (finalToolCalls.length > 0) { + continueLoop = true; + for (const toolCall of finalToolCalls) { + const toolName = toolCall.function.name; + const toolArgsRaw = toolCall.function.arguments; + + let result = await this.executeTool(toolName, toolArgsRaw, session); + + let toolArgs: any = {}; + try { + toolArgs = JSON.parse(toolArgsRaw); + } catch { + // ignore, executeTool already handled it + } + + // Extract metadata from result + let parsedResult: any = null; + try { + parsedResult = JSON.parse(result); + } catch { + // ignore + } + + toolCallCount++; + inputTokens += Math.ceil(toolArgsRaw.length / 4); + outputTokens += Math.ceil(result.length / 4); + + this.log.debug("Subagent tool result for LLM", { + toolName, + resultLength: result.length, + preview: + result.length > 100 ? result.substring(0, 100) + "..." : result, + }); + + currentMessages.push({ + role: "tool", + content: result, + tool_call_id: toolCall.id, + }); + + const parameters: Array<{ name: string; value: string }> = []; + for (const [k, v] of Object.entries(toolArgs)) { + const value = typeof v === "string" ? v : JSON.stringify(v); + // Skip parameters with empty string values + if (value !== "") { + parameters.push({ name: k, value }); + } + } + + collectedToolCalls.push({ + name: toolName, + callId: toolCall.id, + parameters, + response: result, + fileOperation: parsedResult?.data?.fileOperation, + subagentStats: parsedResult?.data?.subagentStats, + }); + } + } else { + this.log.info( + "Subagent loop terminating: no tool calls found in this iteration.", + ); + } + + // Check for queued user messages and inject them before next LLM call + const pendingMessages = this.getPendingUserMessages(); + if (pendingMessages.length > 0) { + this.log.info("Injecting queued user messages in subagent", { + count: pendingMessages.length, + }); + for (const msg of pendingMessages) { + currentMessages.push({ + role: "user", + content: msg.content, + }); + // Emit event so UI can display the message + this.log.debug("Emitting queued_message_sent event", { + displayName: msg.displayName, + content: msg.content.substring(0, 50), + }); + this.emit("queued_message_sent", { + displayName: msg.displayName, + content: msg.content, + }); + } + // Continue loop to process the injected messages + continueLoop = true; + } + + // Save this turn's history + const turnHistoryId = await this.saveChatHistory( + session, + iterations === 1 ? messages[messages.length - 1]?.content || "" : "...", + iterationThinking, + iterationResponse, + undefined, + collectedToolCalls, + undefined, + true, + inputTokens, + outputTokens, + ); + historyIds.push(turnHistoryId); + + if (lastFinishReason === "length") { + this.log.info("Subagent loop continuing: finish_reason was 'length'."); + currentMessages.push({ + role: "user", + content: "Please continue.", + }); + continueLoop = true; + lastFinishReason = undefined; + } + } + + // Update global session stats + await ChatSession.findByIdAndUpdate(session._id, { + $inc: { "stats.toolCallCount": toolCallCount }, + }); + + // Ensure we have a response to return to the parent + if (!fullResponse.trim()) { + if (toolCallCount > 0) { + fullResponse = `Subagent task completed. Executed ${toolCallCount} tool calls to perform the requested actions.`; + } else { + fullResponse = "Subagent task completed without additional actions."; + } + this.log.info("Subagent provided empty response; synthesized summary.", { + toolCallCount, + }); + } + + this.log.info("Subagent chat stream finished.", { + iterations, + fullResponseLength: fullResponse.length, + toolCallCount, + }); + + return { + response: fullResponse, + thinking: fullThinking, + stats: { inputTokens, outputTokens, toolCallCount }, + historyIds, + }; + } + + private async autoNameSession( + session: IChatSession, + prompt: string, + response: string, + ): Promise { + try { + const user = session.user as IUser; + const userId = user._id.toString(); + const utilityModel = await AiService.getUtilityModel(userId); + + const namePrompt = + "Generate a short descriptive name (3-5 words) for this conversation based on the first user message and AI response. Just return the name, nothing else.\n\nUser message: " + + prompt.trim() + + "\n\nAI response: " + + response.substring(0, 500); + + const suggestedName = await this.generate(userId, namePrompt, { + model: utilityModel, + }); + const finalName = suggestedName + .trim() + .replace(/^"|"$/g, "") + .substring(0, 100); + + if (finalName) { + session.name = finalName; + await session.save(); + this.log.info("Auto-named session", { + sessionId: session._id, + name: finalName, + }); + this.emit("session_updated", { + sessionId: session._id.toString(), + name: finalName, + }); + } + } catch (error) { + this.log.warn("Failed to auto-name session", { + sessionId: session._id, + error, + }); + } + } + + async generate( + userId: string, + prompt: string, + options?: { model?: string; systemPrompt?: string; tools?: string[] }, + ): Promise { + const messages: ChatMessage[] = []; + + if (options?.systemPrompt) { + messages.push({ role: "system", content: options.systemPrompt }); + } + + messages.push({ role: "user", content: prompt }); + + const tools = options?.tools + ? getToolsBySlugs(options.tools).map((t) => t.definition as any) + : ([] as any[]); + + const model = options?.model; + if (!model) { + throw new Error("No LLM model specified for generation"); + } + + const client = await AiService.getUtilityClient(userId); + client.on("finish_reason", (reason) => { + this.emit("finish_reason", { reason }); + }); + const response = await client.chat(messages, tools, model); + + return response.content; + } + + private async buildAgentContext( + session: IChatSession, + currentPrompt: string, + ): Promise { + const messages: ChatMessage[] = []; + const mode = session.mode; + + const systemContent = await this.buildAgentSystemPrompt(session); + messages.push({ role: "system", content: systemContent }); + + await fs.writeFile( + path.join( + env.installRoot, + "logs", + `agent.system.${mode}.${session._id}.md`, + ), + systemContent, + "utf-8", + ); + + const history = await ChatHistory.find({ + session: session._id, + isSubagent: { $ne: true }, + }) + .sort({ createdAt: -1 }) + .lean(); + + for (const turn of history.reverse()) { + messages.push({ role: "user", content: turn.prompt }); + + // Include error information for failed turns + if (turn.status === "failed" && turn.error) { + const errorContext = `\n\n[ERROR: ${turn.error.message}]`; + const toolCallContext = + turn.toolCalls && turn.toolCalls.length > 0 + ? `\n[Tool calls made before error: ${turn.toolCalls.map((tc) => tc.tool.name).join(", ")}]` + : ""; + const thinkingContext = turn.response?.thinking + ? `\n[Thinking before error: ${turn.response.thinking.substring(0, 200)}${turn.response.thinking.length > 200 ? "..." : ""}]` + : ""; + + messages.push({ + role: "assistant", + content: `[Turn failed${errorContext}${toolCallContext}${thinkingContext}]`, + }); + } else if (turn.response?.message) { + messages.push({ role: "assistant", content: turn.response.message }); + } + } + + messages.push({ role: "user", content: currentPrompt }); + + const modeFilteredTools = getToolsByMode(mode); + let availableTools = + session.type === ChatSessionType.Extension + ? modeFilteredTools + : getToolsExcludingCategory("browser").filter((t) => + modeFilteredTools.includes(t), + ); + + // Filter tools based on user configuration + const userId = (session.user as IUser)._id.toString(); + const userConfiguredTools: DtpTool[] = []; + + for (const tool of availableTools) { + if (tool.requiresUserConfig()) { + const isConfigured = await tool.checkUserConfig(userId); + if (isConfigured) { + userConfiguredTools.push(tool); + } else { + this.log.debug( + `Tool ${tool.slug} excluded: user configuration required but not found`, + { + userId, + toolSlug: tool.slug, + }, + ); + } + } else { + userConfiguredTools.push(tool); + } + } + + availableTools = userConfiguredTools; + + const tools = availableTools.map((t: any) => t.definition); + + this.log.debug("tools available to agent this turn", { + toolCount: tools.length, + toolNames: tools.map((t) => t.function.name), + }); + + return { messages, tools }; + } + + private async buildAgentSystemPrompt(session: IChatSession): Promise { + const user = session.user as IUser; + const mode = session.mode; + const promptFilePath = path.join( + env.installRoot, + "data", + "prompts", + "agent", + mode, + "system.md", + ); + + let prompt = await fs.readFile(promptFilePath, "utf-8"); + prompt += + "\n\n## SESSION INFORMATION\n\n- Session ID: " + + session._id + + "\n- Session Type: " + + session.type + + "\n- Created At: " + + session.createdAt.toISOString(); + prompt += + "\n\n## USER INFORMATION\n\n- User ID: " + + user._id + + "\n- Username: " + + user.username + + "\n- Display Name: " + + user.displayName; + prompt += + "\n\n## SYSTEM INFORMATION\n\n- Current Time: " + + new Date().toISOString() + + "\n- Gadget Version: " + + env.pkg.version + + "\n- Timezone: " + + env.timezone; + + try { + const agentConfig = await fs.readFile("AGENTS.md", "utf-8"); + this.log.debug("integrating AGENTS.md into system prompt"); + prompt += "\n\n## AGENT CONFIGURATION\n\n" + agentConfig; + } catch (error) { + this.log.debug("AGENTS.md file not found or failed to load.", { + error: error instanceof Error ? error.message : String(error), + }); + } + + prompt += "\n\n## PINBOARD\n\n"; + if (session.pins.length > 0) { + for (const pin of session.pins) { + prompt += "- [" + pin._id?.toString() + "] " + pin.content + "\n"; + } + } else { + prompt += "The pinboard is empty. Use the `pin_add` tool to add notes.\n"; + } + + return this.renderSystemPromptTemplate(prompt, mode); + } + + async renderSystemPromptTemplate( + prompt: string, + _mode: string, + ): Promise { + let content; + + /* + * subagents.md + */ + content = await fs.readFile( + path.join(env.installRoot, "data", "prompts", "common", "subagents.md"), + "utf-8", + ); + prompt = prompt.replace("{{subagent_section}}", content.trim()); + + /* + * scope-block.md + */ + content = await fs.readFile( + path.join(env.installRoot, "data", "prompts", "common", "scope-block.md"), + "utf-8", + ); + prompt = prompt.replace("{{scope_block}}", content.trim()); + + return prompt; + } + + private async streamChat( + session: IChatSession, + messages: ChatMessage[], + tools: ToolDefinition[], + llm: string | undefined | null, + userId: string, + historyId: string, + ): Promise<{ + response: string; + thinking: string; + collectedToolCalls: Array<{ + name: string; + callId: string; + parameters: Array<{ name: string; value: string }>; + response: string; + fileOperation?: unknown; + subagentStats?: unknown; + }>; + subagentHistoryIds: string[]; + inputTokens: number; + outputTokens: number; + }> { + let fullResponse = ""; + let fullThinking = ""; + const collectedToolCalls: Array<{ + name: string; + callId: string; + parameters: Array<{ name: string; value: string }>; + response: string; + fileOperation?: unknown; + subagentStats?: unknown; + }> = []; + const subagentHistoryIds: string[] = []; + let toolCallCount = 0; + let inputTokens = 0; + let outputTokens = 0; + + // Estimate input tokens from message content (rough estimate: 4 chars per token) + const inputText = messages.map((m) => m.content).join(" "); + inputTokens = Math.ceil(inputText.length / 4); + + if (!llm) { + throw new Error("No LLM model configured for this agent"); + } + + const client = await AiService.getAgentClient(userId); + client.on("finish_reason", (reason) => { + this.emit("finish_reason", { reason }); + }); + + let chatOptions; + try { + const providerInfo = await AiService.getAgentProviderInfo(userId); + if (providerInfo) { + chatOptions = await AiService.getModelChatOptions( + userId, + providerInfo.providerId, + providerInfo.model, + ); + } + } catch (err) { + this.log.warn("Failed to get model settings, using defaults", { + error: err, + }); + } + + let currentMessages: ChatMessage[] = [...messages]; + let continueLoop = true; + let lastFinishReason: string | undefined; + let iterations = 0; + + // Create abort controller for this operation + const abortController = new AbortController(); + this.setAbortController(abortController); + + while (continueLoop) { + // Check for abort signal at the start of each iteration + if (abortController.signal.aborted) { + this.log.info("Agent operation was aborted"); + this.clearAbortController(); + throw new Error("Operation aborted"); + } + iterations++; + continueLoop = false; + + // Check for queued user messages at the START of each iteration + const pendingMessagesAtStart = this.getPendingUserMessages(); + if (pendingMessagesAtStart.length > 0) { + this.log.info("Injecting queued user messages at start of iteration", { + count: pendingMessagesAtStart.length, + iteration: iterations, + }); + for (const msg of pendingMessagesAtStart) { + currentMessages.push({ + role: "user", + content: msg.content, + }); + } + // Continue loop to process the injected messages + continueLoop = true; + } + + let iterationThinking = ""; + let iterationResponse = ""; + const toolCallMap = new Map(); + + this.log.debug("Starting AI chat request", { + iteration: iterations, + messageCount: currentMessages.length, + hasTools: tools.length > 0, + model: llm, + }); + + let stream: AsyncGenerator; + try { + stream = await client.streamChat( + currentMessages, + tools, + llm, + chatOptions, + ); + } catch (chatError) { + this.log.error("Failed to create chat stream", { + error: + chatError instanceof Error ? chatError.message : String(chatError), + model: llm, + }); + throw chatError; + } + + for await (const chunk of stream) { + // Check for abort signal during streaming + if (abortController.signal.aborted) { + this.log.info("Agent operation was aborted during streaming"); + this.clearAbortController(); + throw new Error("Operation aborted"); + } + + if (chunk.thinking) { + iterationThinking += chunk.thinking; + fullThinking += chunk.thinking; + this.emit("thinking", { text: chunk.thinking }); + } + + if (chunk.content) { + iterationResponse += chunk.content; + fullResponse += chunk.content; + outputTokens += Math.ceil(chunk.content.length / 4); + this.emit("message", { text: chunk.content }); + } + + if (chunk.done && chunk.finish_reason) { + lastFinishReason = chunk.finish_reason; + } + + if (chunk.toolCall) { + const tcDelta = chunk.toolCall; + const index = tcDelta.index ?? 0; + let existing = toolCallMap.get(index); + + if (!existing) { + existing = { + id: + tcDelta.id ?? "call-" + Math.random().toString(36).slice(2, 9), + type: "function", + function: { + name: tcDelta.function?.name ?? "", + arguments: "", + }, + }; + toolCallMap.set(index, existing); + + if (existing.function.name) { + this.emit("tool_call", { + name: existing.function.name, + arguments: "", + }); + } + } else { + if (tcDelta.id) existing.id = tcDelta.id; + if (tcDelta.function?.name) { + const oldName = existing.function.name; + existing.function.name = tcDelta.function.name; + if (!oldName && existing.function.name) { + this.emit("tool_call", { + name: existing.function.name, + arguments: "", + }); + } + } + } + + if (tcDelta.function?.arguments !== undefined) { + const deltaArgs = + typeof tcDelta.function.arguments === "string" + ? tcDelta.function.arguments + : JSON.stringify(tcDelta.function.arguments); + + const currentArgs = existing.function.arguments; + const trimmedDelta = deltaArgs.trim(); + const isFullObject = + trimmedDelta.startsWith("{") && trimmedDelta.endsWith("}"); + + if ( + isFullObject && + (currentArgs.length === 0 || currentArgs === deltaArgs) + ) { + existing.function.arguments = deltaArgs; + } else { + existing.function.arguments += deltaArgs; + } + } + } + } + + const finalToolCalls = Array.from(toolCallMap.values()); + + currentMessages.push({ + role: "assistant", + content: iterationResponse, + tool_calls: finalToolCalls.length > 0 ? finalToolCalls : undefined, + }); + + // Incrementally update ChatHistory with thinking and response from this iteration + if (iterationThinking || iterationResponse) { + await ChatHistory.findByIdAndUpdate(historyId, { + "response.thinking": iterationThinking, + "response.message": iterationResponse, + }); + } + + if (finalToolCalls.length > 0) { + continueLoop = true; + for (const toolCall of finalToolCalls) { + const toolName = toolCall.function.name; + const toolArgsRaw = toolCall.function.arguments; + + let result = await this.executeTool(toolName, toolArgsRaw, session); + + toolCallCount++; + + let toolArgs: any = {}; + try { + toolArgs = JSON.parse(toolArgsRaw); + } catch { + // ignore + } + + let parsedResult: any = null; + try { + parsedResult = JSON.parse(result); + } catch { + // ignore + } + + this.log.debug("Tool result for LLM", { + toolName, + resultLength: result.length, + preview: + result.length > 100 ? result.substring(0, 100) + "..." : result, + }); + + if (parsedResult?.success && parsedResult.data?.imageBase64) { + try { + const processed = await processImageForLlm( + parsedResult.data.imageBase64, + ); + currentMessages.push({ + role: "tool", + content: `Screenshot captured (${processed.metadata.width}x${processed.metadata.height})`, + images: [processed.base64], + tool_call_id: toolCall.id, + }); + } catch (imgErr) { + currentMessages.push({ + role: "tool", + content: result, + tool_call_id: toolCall.id, + }); + } + } else { + currentMessages.push({ + role: "tool", + content: result, + tool_call_id: toolCall.id, + }); + } + + const parameters: Array<{ name: string; value: string }> = []; + for (const [k, v] of Object.entries(toolArgs)) { + const value = typeof v === "string" ? v : JSON.stringify(v); + // Skip parameters with empty string values + if (value !== "") { + parameters.push({ name: k, value }); + } + } + + if (parsedResult?.data?.historyIds) { + subagentHistoryIds.push(...parsedResult.data.historyIds); + } + + collectedToolCalls.push({ + name: toolName, + callId: toolCall.id, + parameters, + response: result, + fileOperation: parsedResult?.data?.fileOperation, + subagentStats: parsedResult?.data?.subagentStats, + }); + + this.emit("tool_result", { + tool: toolName, + result: result, + fileOperation: parsedResult?.data?.fileOperation, + subagentStats: parsedResult?.data?.subagentStats, + subagentPrompt: parsedResult?.data?.subagentPrompt, + subagentType: parsedResult?.data?.subagentType, + }); + + // Incrementally update ChatHistory with tool calls from this iteration + await ChatHistory.findByIdAndUpdate(historyId, { + $push: { + toolCalls: { + tool: { + name: toolName, + callId: toolCall.id, + parameters, + }, + response: result, + fileOperation: parsedResult?.data?.fileOperation ?? undefined, + subagentStats: parsedResult?.data?.subagentStats ?? undefined, + }, + }, + $inc: { + inputTokens: Math.ceil(toolArgsRaw.length / 4), + outputTokens: Math.ceil(result.length / 4), + }, + }); + } + } else { + this.log.info( + "Agent loop terminating: no tool calls found in this iteration.", + ); + } + + // Check for queued user messages and inject them before next LLM call + const newPendingMessages = this.getPendingUserMessages(); + if (newPendingMessages.length > 0) { + this.log.info("Injecting queued user messages", { + count: newPendingMessages.length, + }); + for (const msg of newPendingMessages) { + currentMessages.push({ + role: "user", + content: msg.content, + }); + // Emit event so UI can display the message + this.emit("queued_message_sent", { + displayName: msg.displayName, + content: msg.content, + }); + } + // Continue loop to process the injected messages + continueLoop = true; + } + + if (lastFinishReason === "length") { + this.log.info("Agent loop continuing: finish_reason was 'length'."); + currentMessages.push({ + role: "user", + content: "Please continue.", + }); + continueLoop = true; + lastFinishReason = undefined; + } + } + + await ChatSession.findByIdAndUpdate(session._id, { + $inc: { "stats.toolCallCount": toolCallCount }, + }); + + this.emit("done", { + fullResponse, + fullThinking, + inputTokens, + outputTokens, + }); + + this.log.info("Agent chat stream finished.", { + iterations, + fullResponseLength: fullResponse.length, + toolCallCount, + inputTokens, + outputTokens, + }); + + // Clear abort controller on successful completion + this.clearAbortController(); + + // Clear any remaining queued messages on successful completion + this.clearPendingUserMessages(); + + return { + response: fullResponse, + thinking: fullThinking, + collectedToolCalls, + subagentHistoryIds, + inputTokens, + outputTokens, + }; + } + + private async executeTool( + toolName: string, + args: string, + session: IChatSession, + ): Promise { + const tool = getToolByName(toolName); + if (!tool) { + const error = "Unknown tool: " + toolName; + this.log.error("Tool not found", { toolName }); + return JSON.stringify({ success: false, error, message: error }); + } + + try { + this.log.debug("Tool execution started", { toolName, args }); + + const parsedArgs = JSON.parse(args); + const context = { session }; + + const result = await tool.execute(context, parsedArgs); + + this.log.debug("Tool execution completed", { + toolName, + resultLength: result.length, + }); + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.log.error("Tool execution failed: " + toolName, { + tool: toolName, + arguments: args, + error: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + }); + + try { + this.emit("tool_error", { + tool: toolName, + message: "Tool execution failed: " + errorMessage, + }); + } catch (emitError) { + this.log.error("Failed to emit tool error event", { + toolName, + emitError, + }); + } + + return JSON.stringify({ + success: false, + error: "TOOL_EXECUTION_FAILED", + message: errorMessage, + }); + } + } + + private async saveChatHistory( + session: IChatSession, + prompt: string, + thinking: string | undefined, + message: string | undefined, + error?: { message: string; stack?: string }, + toolCalls?: Array<{ + name: string; + callId: string; + parameters: Array<{ name: string; value: string }>; + response: string; + fileOperation?: unknown; + subagentStats?: unknown; + }>, + subagentHistoryIds?: string[], + isSubagent = false, + inputTokens = 0, + outputTokens = 0, + ): Promise { + const status = error ? "failed" : "success"; + const user = session.user as IUser; + + const toolCallDocs = (toolCalls ?? []).map((tc) => ({ + tool: { + name: tc.name, + callId: tc.callId, + parameters: tc.parameters, + }, + response: tc.response, + fileOperation: tc.fileOperation ?? undefined, + subagentStats: tc.subagentStats ?? undefined, + })); + + const fileOpDocs = toolCallDocs + .filter((tc) => tc.fileOperation != null) + .map((tc) => tc.fileOperation); + + const history = new ChatHistory({ + user: user._id, + session: session._id, + prompt, + mode: session.mode, + toolCalls: toolCallDocs, + fileOperations: fileOpDocs, + response: { thinking, message }, + status, + isSubagent, + subagentHistory: subagentHistoryIds || [], + error: error + ? { message: error.message, stack: error.stack, timestamp: new Date() } + : undefined, + inputTokens, + outputTokens, + }); + const saved = await history.save(); + return saved._id.toString(); + } +} + +export default new AgentService(); diff --git a/docs/archive/services/ai.ts b/docs/archive/services/ai.ts new file mode 100644 index 0000000..cc6fc5f --- /dev/null +++ b/docs/archive/services/ai.ts @@ -0,0 +1,307 @@ +// src/services/ai.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { + AiProvider, + type ApiType, + type IAiModelSettings, +} from "../models/ai-provider.js"; +import { User } from "../models/user.js"; +import { OllamaAiClient } from "../lib/ai-clients/ollama-client.js"; +import { OpenAiClient } from "../lib/ai-clients/openai-client.js"; +import type { AiClient, ChatOptions } from "../lib/ai-client.js"; + +import { DtpService } from "../lib/service.js"; + +interface ProviderData { + apiType: ApiType; + baseUrl: string; + apiKey: string; +} + +class AiService extends DtpService { + get name(): string { + return "AiService"; + } + + get slug(): string { + return "ai"; + } + + constructor() { + super(); + } + + async start(): Promise { + this.log.info("service started"); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + getClient(provider: ProviderData): AiClient { + if (provider.apiType === "ollama") { + return new OllamaAiClient(provider.baseUrl, provider.apiKey); + } + return new OpenAiClient(provider.baseUrl, provider.apiKey); + } + + async getAgentClient(userId: string): Promise { + const user = await User.findById(userId) + .select("+connections.ai.agentProviderId +connections.ai.agentModel") + .lean(); + if (!user) { + throw new Error("User not found"); + } + + const aiConfig = user.connections?.ai; + if (!aiConfig?.agentProviderId) { + throw new Error("No agent provider configured"); + } + + const provider = await AiProvider.findById(aiConfig.agentProviderId) + .select("+apiKey") + .lean(); + if (!provider) { + throw new Error("Agent provider not found"); + } + + return this.getClient({ + apiType: provider.apiType, + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + }); + } + + async getAgentProviderInfo( + userId: string, + ): Promise<{ providerId: string; model: string } | null> { + const user = await User.findById(userId) + .select("+connections.ai.agentProviderId +connections.ai.agentModel") + .lean(); + if (!user) { + return null; + } + + const aiConfig = user.connections?.ai; + if (!aiConfig?.agentProviderId || !aiConfig?.agentModel) { + return null; + } + + return { + providerId: aiConfig.agentProviderId.toString(), + model: aiConfig.agentModel, + }; + } + + async getVectorClient(userId: string): Promise { + const user = await User.findById(userId) + .select("+connections.ai.vectorProviderId +connections.ai.vectorModel") + .lean(); + if (!user) { + throw new Error("User not found"); + } + + const aiConfig = user.connections?.ai; + if (!aiConfig?.vectorProviderId) { + throw new Error("No vector provider configured"); + } + + const provider = await AiProvider.findById(aiConfig.vectorProviderId) + .select("+apiKey") + .lean(); + if (!provider) { + throw new Error("Vector provider not found"); + } + + return this.getClient({ + apiType: provider.apiType, + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + }); + } + + async getUtilityClient(userId: string): Promise { + const user = await User.findById(userId) + .select("+connections.ai.utilityProviderId +connections.ai.utilityModel") + .lean(); + if (!user) { + throw new Error("User not found"); + } + + const aiConfig = user.connections?.ai; + if (!aiConfig?.utilityProviderId) { + throw new Error("No utility provider configured"); + } + + const provider = await AiProvider.findById(aiConfig.utilityProviderId) + .select("+apiKey") + .lean(); + if (!provider) { + throw new Error("Utility provider not found"); + } + + return this.getClient({ + apiType: provider.apiType, + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + }); + } + + async getAgentModel(userId: string): Promise { + const user = await User.findById(userId) + .select("+connections.ai.agentModel") + .lean(); + if (!user) { + throw new Error("User not found"); + } + return user.connections?.ai?.agentModel || ""; + } + + async getVectorModel(userId: string): Promise { + const user = await User.findById(userId) + .select("+connections.ai.vectorModel") + .lean(); + if (!user) { + throw new Error("User not found"); + } + return user.connections?.ai?.vectorModel || ""; + } + + async getUtilityModel(userId: string): Promise { + const user = await User.findById(userId) + .select("+connections.ai.utilityModel") + .lean(); + if (!user) { + throw new Error("User not found"); + } + return user.connections?.ai?.utilityModel || ""; + } + + async getModelSettings( + userId: string, + providerId: string, + modelId: string, + ): Promise { + const provider = await AiProvider.findOne({ + _id: providerId, + user: userId, + }).lean(); + + if (!provider) { + return null; + } + + const model = provider.models.find((m) => m.id === modelId); + return model?.settings ?? null; + } + + async getModelChatOptions( + userId: string, + providerId: string, + modelId: string, + ): Promise { + const provider = await AiProvider.findOne({ + _id: providerId, + user: userId, + }).lean(); + + if (!provider) { + return {}; + } + + const model = provider.models.find((m) => m.id === modelId); + const settings = model?.settings; + + if (!settings) { + const defaults = this.getDefaultOptions( + provider.apiType, + model?.contextWindow, + ); + return defaults; + } + + return { + temperature: settings.temperature, + topP: settings.topP, + topK: settings.topK, + numCtx: settings.numCtx, + }; + } + + private getDefaultOptions( + apiType: ApiType, + contextWindow?: number, + ): ChatOptions { + if (apiType === "ollama") { + return { + temperature: 0.8, + topP: 0.9, + topK: 40, + numCtx: contextWindow || 2048, + }; + } + return { + temperature: 0.7, + topP: 1.0, + }; + } + + async isConfigured(userId: string): Promise { + const user = await User.findById(userId) + .select( + "+connections.ai.agentProviderId +connections.ai.vectorProviderId +connections.ai.utilityProviderId", + ) + .lean(); + if (!user) { + return false; + } + + const aiConfig = user.connections?.ai; + return !!( + aiConfig?.agentProviderId && + aiConfig?.vectorProviderId && + aiConfig?.utilityProviderId + ); + } + + async getModelDisplay( + userId: string, + slot: "agent" | "vector" | "util", + ): Promise { + const user = await User.findById(userId) + .select( + "+connections.ai.agentModel +connections.ai.agentProviderId +connections.ai.vectorModel +connections.ai.vectorProviderId +connections.ai.utilityModel +connections.ai.utilityProviderId", + ) + .lean(); + if (!user) return "N/A"; + + const aiConfig = user.connections?.ai; + let model = ""; + let providerId: string | null = null; + + if (slot === "agent") { + model = aiConfig?.agentModel || ""; + providerId = aiConfig?.agentProviderId?.toString() || null; + } else if (slot === "vector") { + model = aiConfig?.vectorModel || ""; + providerId = aiConfig?.vectorProviderId?.toString() || null; + } else { + model = aiConfig?.utilityModel || ""; + providerId = aiConfig?.utilityProviderId?.toString() || null; + } + + if (!model || !providerId) return "N/A"; + + const provider = await AiProvider.findById(providerId) + .select("name") + .lean(); + const providerName = provider?.name || "Unknown"; + + return `${model} (${providerName})`; + } +} + +export default new AiService(); diff --git a/docs/archive/services/auth.ts b/docs/archive/services/auth.ts new file mode 100644 index 0000000..225a875 --- /dev/null +++ b/docs/archive/services/auth.ts @@ -0,0 +1,457 @@ +// src/services/auth.ts +// Copyright (C) 2026 DTP Technologies, LLC +// All Rights Reserved + +import jwt from "jsonwebtoken"; + +import { DtpService } from "../lib/service.js"; +import env from "../config/env.js"; +import UserService from "./user.js"; +import SessionService from "./session.js"; +import { IUser } from "../models/user.js"; +import { ISession } from "../models/session.js"; + +export interface JwtPayload { + userId: string; + username: string; + sessionId: string; +} + +export interface AuthTokens { + accessToken: string; + expiresIn: string; +} + +export interface AuthResult { + success: boolean; + user: IUser; + session: ISession; + tokens: AuthTokens; +} + +export class AuthService extends DtpService { + get name(): string { + return "AuthService"; + } + get slug(): string { + return "auth"; + } + + private jwtSecret: string; + private jwtExpiresIn: number = 24 * 60 * 60; // 24 hours in seconds + private jwtRefreshThreshold: number = 20; // Refresh when < 20 hours remaining + + constructor() { + super(); + const secret = env.user.jwtSecret; + if (!secret) { + throw new Error( + "JWT secret not configured. Set environmentSalt or JWT_SECRET in config.", + ); + } + this.jwtSecret = secret; + } + + async start(): Promise { + this.log.info("service started", { + jwtExpiresIn: this.jwtExpiresIn, + refreshThreshold: this.jwtRefreshThreshold, + }); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + /** + * Authenticate user with username and password + */ + async authenticate( + username: string, + password: string, + ipAddress?: string, + userAgent?: string, + ): Promise { + this.log.info("authenticate() called", { username, ipAddress, userAgent }); + + // Validate input + if (!username || !password) { + this.log.error("authenticate() failed - missing credentials", { + hasUsername: !!username, + hasPassword: !!password, + }); + throw new Error("Username and password are required"); + } + + this.log.debug("authenticate() - looking up user", { username }); + + // Find user with credentials + const user = await UserService.getUserByUsernameWithCredentials(username); + + if (!user) { + this.log.error("authenticate() failed - user not found", { username }); + throw new Error("Invalid username or password"); + } + + this.log.debug("authenticate() - user found, verifying password", { + userId: user._id, + username: user.username, + }); + + // Verify password + const isValid = await UserService.verifyPassword(user, password); + if (!isValid) { + this.log.error("authenticate() failed - invalid password", { + userId: user._id, + username: user.username, + }); + throw new Error("Invalid username or password"); + } + + this.log.debug("authenticate() - password valid, checking ban status", { + userId: user._id, + username: user.username, + }); + + // Check if user is banned + if (UserService.isUserBanned(user)) { + this.log.error("authenticate() failed - user is banned", { + userId: user._id, + username: user.username, + }); + throw new Error("Account has been suspended"); + } + + this.log.debug( + "authenticate() - user not banned, revoking existing sessions", + { + userId: user._id, + username: user.username, + }, + ); + + // Revoke any existing sessions (single session policy) + await SessionService.revokeAllForUser(user._id.toString()); + this.log.debug("authenticate() - existing sessions revoked", { + userId: user._id, + username: user.username, + }); + + // Create new session + this.log.debug("authenticate() - creating new session", { + userId: user._id, + username: user.username, + ipAddress, + userAgent, + }); + const session = await SessionService.create( + user._id.toString(), + ipAddress, + userAgent, + ); + this.log.debug("authenticate() - session created", { + sessionId: session._id, + userId: user._id, + username: user.username, + }); + + // Generate JWT token + this.log.debug("authenticate() - generating JWT token", { + sessionId: session._id, + userId: user._id, + username: user.username, + }); + const tokens = this.generateJwtToken(user, session); + this.log.debug("authenticate() - JWT token generated", { + sessionId: session._id, + userId: user._id, + username: user.username, + expiresIn: tokens.expiresIn, + accessTokenLength: tokens.accessToken.length, + }); + + this.log.info("User authenticated successfully", { + userId: user._id, + username: user.username, + sessionId: session._id, + tokenExpiresIn: tokens.expiresIn, + }); + + return { + success: true, + user, + session, + tokens, + }; + } + + /** + * Generate JWT token for user + */ + generateJwtToken(user: IUser, session: ISession): AuthTokens { + this.log.debug("generateJwtToken() - creating JWT", { + userId: user._id, + username: user.username, + sessionId: session._id, + expiresIn: this.jwtExpiresIn, + }); + + const payload: JwtPayload = { + userId: user._id.toString(), + username: user.username, + sessionId: session._id.toString(), + }; + + const accessToken = jwt.sign(payload, this.jwtSecret, { + expiresIn: this.jwtExpiresIn, + }); + + this.log.debug("generateJwtToken() - JWT created", { + sessionId: session._id, + tokenLength: accessToken.length, + first20Chars: accessToken.substring(0, 20) + "...", + }); + + return { + accessToken, + expiresIn: `${this.jwtExpiresIn}s`, + }; + } + + /** + * Verify JWT token and return payload + */ + verifyToken(token: string): JwtPayload | null { + this.log.debug("verifyToken() - verifying token", { + tokenLength: token.length, + first20Chars: token.substring(0, 20) + "...", + }); + + try { + const decoded = jwt.verify(token, this.jwtSecret) as JwtPayload; + this.log.debug("verifyToken() - token verified successfully", { + userId: decoded.userId, + username: decoded.username, + sessionId: decoded.sessionId, + }); + return decoded; + } catch (error) { + const err = error as Error; + this.log.error("verifyToken() - token verification failed", { + errorName: err.name, + errorMessage: err.message, + tokenLength: token.length, + }); + return null; + } + } + + /** + * Check if token needs refresh (returns true if should refresh) + */ + shouldRefreshToken(token: string): boolean { + this.log.debug("shouldRefreshToken() - checking token", { + tokenLength: token.length, + refreshThreshold: this.jwtRefreshThreshold, + }); + + try { + const decoded = jwt.decode(token) as jwt.JwtPayload & JwtPayload; + if (!decoded || !decoded.exp) { + this.log.debug("shouldRefreshToken() - no exp claim, not refreshing"); + return false; + } + + const now = Math.floor(Date.now() / 1000); + const remainingSeconds = decoded.exp - now; + const remainingHours = remainingSeconds / 3600; + + this.log.debug("shouldRefreshToken() - token age analysis", { + exp: decoded.exp, + now, + remainingSeconds, + remainingHours, + shouldRefresh: remainingHours < this.jwtRefreshThreshold, + }); + + return remainingHours < this.jwtRefreshThreshold; + } catch (error) { + const err = error as Error; + this.log.error("shouldRefreshToken() - decode failed", { + errorMessage: err.message, + }); + return false; + } + } + + /** + * Refresh JWT token + */ + async refreshToken(currentToken: string): Promise { + this.log.info("refreshToken() - called", { + tokenLength: currentToken.length, + first20Chars: currentToken.substring(0, 20) + "...", + }); + + const payload = this.verifyToken(currentToken); + if (!payload) { + this.log.error("refreshToken() - verifyToken failed, returning null"); + return null; + } + + this.log.debug("refreshToken() - token verified, checking session", { + sessionId: payload.sessionId, + userId: payload.userId, + }); + + // Validate session still exists + const session = await SessionService.findByToken(payload.sessionId); + if (!session) { + this.log.warn("refreshToken() - session not found", { + sessionId: payload.sessionId, + userId: payload.userId, + }); + return null; + } + + this.log.debug("refreshToken() - session found, fetching user", { + sessionId: session._id, + userId: payload.userId, + }); + + // Get user + const user = await UserService.getUserByIdWithCredentials( + new (await import("mongoose")).Types.ObjectId(payload.userId), + ); + if (!user) { + this.log.error("refreshToken() - user not found", { + userId: payload.userId, + sessionId: payload.sessionId, + }); + return null; + } + + this.log.debug("refreshToken() - user found, extending session", { + userId: user._id, + sessionId: session._id, + }); + + // Extend session + await SessionService.extend(session); + this.log.debug("refreshToken() - session extended", { + sessionId: session._id, + userId: user._id, + }); + + // Generate new token + this.log.info("refreshToken() - generating new token", { + sessionId: session._id, + userId: user._id, + }); + const tokens = this.generateJwtToken(user, session); + this.log.info("refreshToken() - token refreshed successfully", { + sessionId: session._id, + userId: user._id, + expiresIn: tokens.expiresIn, + }); + + return tokens; + } + + /** + * Logout user (revoke session) + */ + async logout(token: string): Promise { + this.log.info("logout() - called", { + tokenLength: token.length, + first20Chars: token.substring(0, 20) + "...", + }); + + const payload = this.verifyToken(token); + if (!payload) { + this.log.warn("logout() - invalid token, returning false"); + return false; + } + + this.log.debug("logout() - token valid, revoking session", { + sessionId: payload.sessionId, + userId: payload.userId, + }); + + const revoked = await SessionService.revoke(payload.sessionId); + if (revoked) { + this.log.info("logout() - session revoked successfully", { + sessionId: payload.sessionId, + userId: payload.userId, + }); + } else { + this.log.warn("logout() - session revoke failed", { + sessionId: payload.sessionId, + userId: payload.userId, + }); + } + return revoked; + } + + /** + * Get cookie options for JWT + */ + getCookieOptions() { + const cookieDomain = env.web.cookieDomain; + + this.log.debug("getCookieOptions() - returning cookie config", { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 24 * 60 * 60 * 1000, + domain: cookieDomain || "(none - default to request host)", + }); + + return { + httpOnly: true, + secure: true, // Always secure - both prod and dev use HTTPS + sameSite: "lax" as const, // Allow cross-site for dev proxy + maxAge: 24 * 60 * 60 * 1000, // 24 hours + ...(cookieDomain ? { domain: cookieDomain } : {}), // Only set domain if configured + }; + } + + /** + * Generate Set-Cookie header to clear auth_token cookie + * This clears ALL duplicate cookies by setting an expired date + */ + getClearCookieHeader(): string { + const options = this.getCookieOptions(); + const expires = "Thu, 01 Jan 1970 00:00:00 GMT"; + + this.log.debug("getClearCookieHeader() - generating clear cookie header", { + domain: options.domain, + expires, + }); + + const parts = ["auth_token=", "Expires=" + expires, "Max-Age=0", "Path=/"]; + + if (options.domain) { + parts.push("Domain=" + options.domain); + } + if (options.httpOnly) { + parts.push("HttpOnly"); + } + if (options.secure) { + parts.push("Secure"); + } + if (options.sameSite) { + parts.push("SameSite=" + options.sameSite); + } + + const header = parts.join("; "); + this.log.debug("getClearCookieHeader() - generated header", { + headerLength: header.length, + header, + }); + + return header; + } +} + +export default new AuthService(); diff --git a/docs/archive/services/chat-session.ts b/docs/archive/services/chat-session.ts new file mode 100644 index 0000000..b7e5344 --- /dev/null +++ b/docs/archive/services/chat-session.ts @@ -0,0 +1,136 @@ +// src/services/chat-session.ts +// Copyright (C) 2026 DTP Technologies, LLC +// All Rights Reserved + +import { + ChatSession, + ChatSessionType, + ChatSessionMode, +} from "../models/chat-session.js"; +import { DtpService } from "../lib/service.js"; + +export interface IChatSessionListItem { + _id: string; + name: string; + lastMessageAt: Date | null; + turnCount: number; + toolCallCount: number; + createdAt: Date; +} + +export class ChatSessionService extends DtpService { + get name(): string { + return "ChatSessionService"; + } + get slug(): string { + return "chat-session"; + } + + async start(): Promise { + this.log.info("service started"); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + async listByUser(userId: string): Promise { + const sessions = await ChatSession.find({ user: userId }) + .sort({ lastMessageAt: -1, createdAt: -1 }) + .lean(); + + return sessions.map((s) => ({ + _id: s._id.toString(), + name: s.name, + lastMessageAt: s.lastMessageAt ?? null, + turnCount: s.stats.turnCount, + toolCallCount: s.stats.toolCallCount, + createdAt: s.createdAt, + })); + } + + async listByProject( + projectId: string, + userId: string, + ): Promise { + const sessions = await ChatSession.find({ + user: userId, + project: projectId, + }) + .sort({ lastMessageAt: -1, createdAt: -1 }) + .lean(); + + return sessions.map((s) => ({ + _id: s._id.toString(), + name: s.name, + lastMessageAt: s.lastMessageAt ?? null, + turnCount: s.stats.turnCount, + toolCallCount: s.stats.toolCallCount, + createdAt: s.createdAt, + })); + } + + async create( + userId: string, + projectId?: string, + name?: string, + ): Promise { + const session = new ChatSession({ + user: userId, + project: projectId, + name: name ?? "New Session", + type: ChatSessionType.Desktop, + mode: ChatSessionMode.Build, + }); + await session.save(); + + this.log.info("ChatSession created", { + sessionId: session._id, + userId, + projectId, + }); + + return { + _id: session._id.toString(), + name: session.name, + lastMessageAt: null, + turnCount: 0, + toolCallCount: 0, + createdAt: session.createdAt, + }; + } + + async findById(sessionId: string): Promise { + const session = await ChatSession.findById(sessionId).lean(); + if (!session) { + return null; + } + + return { + _id: session._id.toString(), + name: session.name, + lastMessageAt: session.lastMessageAt ?? null, + turnCount: session.stats.turnCount, + toolCallCount: session.stats.toolCallCount, + createdAt: session.createdAt, + }; + } + + async updateName(sessionId: string, name: string): Promise { + await ChatSession.findByIdAndUpdate(sessionId, { name }); + this.log.info("ChatSession name updated", { sessionId, name }); + } + + async delete(sessionId: string, userId: string): Promise { + const result = await ChatSession.deleteOne({ + _id: sessionId, + user: userId, + }); + if (result.deletedCount === 0) { + throw new Error("ChatSession not found or not owned by user"); + } + this.log.info("ChatSession deleted", { sessionId, userId }); + } +} + +export default new ChatSessionService(); diff --git a/docs/archive/services/chat.service.ts b/docs/archive/services/chat.service.ts new file mode 100644 index 0000000..1c992ef --- /dev/null +++ b/docs/archive/services/chat.service.ts @@ -0,0 +1,370 @@ +// src/services/chat.service.ts +// Copyright (C) 2026 DTP Technologies, LLC +// All Rights Reserved + +import { Types } from "mongoose"; +import { + ChatSession, + ChatSessionType, + ChatSessionMode, +} from "../models/chat-session.js"; +import { + ChatHistory, + IChatHistory as IChatHistoryModel, + ChatHistoryStatus, +} from "../models/chat-history.js"; +import { DtpService } from "../lib/service.js"; + +export interface IChatSessionListItem { + _id: string; + name: string; + lastMessageAt: Date | null; + turnCount: number; + toolCallCount: number; + inputTokens: number; + outputTokens: number; + createdAt: Date; + mode: ChatSessionMode; +} + +export interface IChatSessionDetail extends IChatSessionListItem { + user: string; + project?: string; + type: ChatSessionType; + pins: Array<{ _id?: string; content: string }>; +} + +export interface IChatHistoryEntry { + _id: string; + sessionId: string; + prompt: string; + response: { + thinking?: string; + message?: string; + }; + status: ChatHistoryStatus; + toolCalls: Array<{ + tool: { + name: string; + callId: string; + parameters: Array<{ name: string; value: string }>; + }; + response?: string; + fileOperation?: { + type: "read" | "write" | "edit" | "shell"; + path?: string; + diff?: string; + linesAdded?: number; + linesRemoved?: number; + isBinary?: boolean; + }; + subagentStats?: { + inputTokens: number; + outputTokens: number; + toolCallCount: number; + }; + }>; + fileOperations: Array<{ + type: "read" | "write" | "edit" | "shell"; + path?: string; + diff?: string; + linesAdded?: number; + linesRemoved?: number; + isBinary?: boolean; + }>; + inputTokens: number; + outputTokens: number; + createdAt: Date; + mode: ChatSessionMode; + isSubagent: boolean; + error?: { + message: string; + stack?: string; + timestamp: Date; + }; +} + +export class ChatService extends DtpService { + get name(): string { + return "ChatService"; + } + get slug(): string { + return "chat"; + } + + async start(): Promise { + this.log.info("service started"); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + // ==================== Session Management ==================== + + async listByProject( + projectId: string, + userId: string, + ): Promise { + const sessions = await ChatSession.find({ + user: userId, + project: projectId, + }) + .sort({ lastMessageAt: -1, createdAt: -1 }) + .lean(); + + return sessions.map((s) => ({ + _id: s._id.toString(), + name: s.name, + lastMessageAt: s.lastMessageAt ?? null, + turnCount: s.stats.turnCount, + toolCallCount: s.stats.toolCallCount, + inputTokens: s.stats.inputTokens, + outputTokens: s.stats.outputTokens, + createdAt: s.createdAt, + mode: s.mode, + })); + } + + async listAll(userId: string): Promise { + const sessions = await ChatSession.find({ + user: userId, + }) + .sort({ lastMessageAt: -1, createdAt: -1 }) + .limit(50) + .lean(); + + return sessions.map((s) => ({ + _id: s._id.toString(), + name: s.name, + lastMessageAt: s.lastMessageAt ?? null, + turnCount: s.stats.turnCount, + toolCallCount: s.stats.toolCallCount, + inputTokens: s.stats.inputTokens, + outputTokens: s.stats.outputTokens, + createdAt: s.createdAt, + mode: s.mode, + })); + } + + async create( + userId: string, + projectId?: string, + name?: string, + ): Promise { + const session = new ChatSession({ + user: userId, + project: projectId, + name: name ?? "New Session", + type: ChatSessionType.Desktop, + mode: ChatSessionMode.Build, + }); + await session.save(); + + this.log.info("ChatSession created", { + sessionId: session._id, + userId, + projectId, + }); + + return { + _id: session._id.toString(), + name: session.name, + lastMessageAt: null, + turnCount: 0, + toolCallCount: 0, + inputTokens: 0, + outputTokens: 0, + createdAt: session.createdAt, + mode: session.mode, + }; + } + + async findById(sessionId: string): Promise { + const session = await ChatSession.findById(sessionId).lean(); + if (!session) { + return null; + } + + const sessionWithProject = await ChatSession.findById(sessionId) + .populate("project", "name") + .lean() as any; + const projectName = (sessionWithProject?.project as any)?.name; + + const userId = + session.user instanceof Types.ObjectId + ? session.user.toString() + : String(session.user); + + return { + _id: session._id.toString(), + name: session.name, + lastMessageAt: session.lastMessageAt ?? null, + turnCount: session.stats.turnCount, + toolCallCount: session.stats.toolCallCount, + inputTokens: session.stats.inputTokens, + outputTokens: session.stats.outputTokens, + createdAt: session.createdAt, + mode: session.mode, + user: userId, + project: projectName, + type: session.type, + pins: session.pins.map((p) => ({ + _id: p._id?.toString(), + content: p.content, + })), + }; + } + + async updateName(sessionId: string, name: string): Promise { + await ChatSession.findByIdAndUpdate(sessionId, { name }); + this.log.info("ChatSession name updated", { sessionId, name }); + } + + async updateMode(sessionId: string, mode: ChatSessionMode): Promise { + await ChatSession.findByIdAndUpdate(sessionId, { mode }); + this.log.info("ChatSession mode updated", { sessionId, mode }); + } + + async delete(sessionId: string, userId: string): Promise { + const result = await ChatSession.deleteOne({ + _id: sessionId, + user: userId, + }); + if (result.deletedCount === 0) { + throw new Error("ChatSession not found or not owned by user"); + } + this.log.info("ChatSession deleted", { sessionId, userId }); + } + + // ==================== History Management ==================== + + async getHistory(sessionId: string): Promise { + const history = await ChatHistory.find({ session: sessionId }) + .sort({ createdAt: 1 }) + .lean(); + + return history.map((h) => ({ + _id: h._id.toString(), + sessionId: h.session.toString(), + prompt: h.prompt, + response: { + thinking: h.response?.thinking, + message: h.response?.message, + }, + status: h.status, + toolCalls: h.toolCalls.map((tc) => ({ + tool: { + name: tc.tool.name, + callId: tc.tool.callId, + parameters: tc.tool.parameters.map((p) => ({ + name: p.name, + value: p.value, + })), + }, + response: tc.response, + fileOperation: tc.fileOperation, + subagentStats: tc.subagentStats, + })), + fileOperations: h.fileOperations, + inputTokens: h.inputTokens, + outputTokens: h.outputTokens, + createdAt: h.createdAt, + mode: h.mode, + isSubagent: h.isSubagent ?? false, + subagentHistory: h.subagentHistory || [], + error: h.error, + })); + } + + async createHistoryEntry( + sessionId: string, + userId: string, + prompt: string, + mode: ChatSessionMode, + ): Promise { + const entry = new ChatHistory({ + session: sessionId, + user: userId, + prompt, + mode, + status: ChatHistoryStatus.Processing, + }); + await entry.save(); + + // Update session stats + await ChatSession.findByIdAndUpdate(sessionId, { + $inc: { + "stats.turnCount": 1, + "stats.inputTokens": entry.inputTokens, + "stats.outputTokens": entry.outputTokens, + }, + lastMessageAt: new Date(), + }); + + this.log.info("ChatHistory entry created", { + historyId: entry._id, + sessionId, + userId, + }); + + return { + _id: entry._id.toString(), + sessionId, + prompt: entry.prompt, + response: { + thinking: entry.response?.thinking, + message: entry.response?.message, + }, + status: entry.status, + toolCalls: [], + fileOperations: [], + inputTokens: 0, + outputTokens: 0, + createdAt: entry.createdAt, + mode: entry.mode, + isSubagent: entry.isSubagent ?? false, + }; + } + + async updateHistoryEntry( + historyId: string, + updates: Partial<{ + response: { thinking?: string; message?: string }; + status: ChatHistoryStatus; + toolCalls: IChatHistoryModel["toolCalls"]; + fileOperations: IChatHistoryModel["fileOperations"]; + inputTokens: number; + outputTokens: number; + error: { message: string; stack?: string; timestamp: Date }; + }>, + ): Promise { + const updateData: any = {}; + if (updates.response !== undefined) { + updateData.response = updates.response; + } + if (updates.status !== undefined) { + updateData.status = updates.status; + } + if (updates.toolCalls !== undefined) { + updateData.toolCalls = updates.toolCalls; + } + if (updates.fileOperations !== undefined) { + updateData.fileOperations = updates.fileOperations; + } + if (updates.inputTokens !== undefined) { + updateData.inputTokens = updates.inputTokens; + } + if (updates.outputTokens !== undefined) { + updateData.outputTokens = updates.outputTokens; + } + if (updates.error !== undefined) { + updateData.error = updates.error; + } + + await ChatHistory.findByIdAndUpdate(historyId, updateData); + this.log.info("ChatHistory entry updated", { historyId }); + } +} + +export default new ChatService(); diff --git a/docs/archive/services/csrf-token.ts b/docs/archive/services/csrf-token.ts new file mode 100644 index 0000000..8e5ba18 --- /dev/null +++ b/docs/archive/services/csrf-token.ts @@ -0,0 +1,127 @@ +// app/services/csrf-token.ts +// Copyright (C) 2026 DTP Technologies, LLC +// All Rights Reserved + +import { Request, Response, NextFunction, RequestHandler } from "express"; +import { v4 as uuidv4 } from "uuid"; +import dayjs from "dayjs"; + +import CsrfToken, { ICsrfToken } from "../models/csrf-token.js"; + +import { DtpService } from "../lib/service.js"; + +export interface CsrfTokenOptions { + name: string; + expiresMinutes: number; + allowReuse: boolean; +} + +export class CsrfTokenService extends DtpService { + get name(): string { + return "CsrfTokenService"; + } + get slug(): string { + return "csrfToken"; + } + + constructor() { + super(); + } + + async start(): Promise { + this.log.info("service started"); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + middleware(options: CsrfTokenOptions): RequestHandler { + return async ( + req: Request, + _res: Response, + next: NextFunction, + ): Promise => { + const requestToken = req.body[`csrf-token-${options.name}`]; + if (!requestToken) { + this.log.error("missing CSRF token", { name: options.name }); + const error = new Error("Must include valid CSRF token"); + error.statusCode = 401; + return next(error); + } + + const token = await CsrfToken.findOne({ token: requestToken }); + if (!token) { + const error = new Error("CSRF request token is invalid"); + error.statusCode = 401; + return next(error); + } + if (token.ip !== req.ip) { + const error = new Error("CSRF request token client mismatch"); + error.statusCode = 401; + return next(error); + } + if (token.claimedAt && !options.allowReuse) { + const error = new Error( + "Your request can't be accepted. Please refresh the page and try again.", + ); + error.statusCode = 401; + return next(error); + } + + if (token.user) { + if (!req.user) { + const error = new Error("Must be logged in"); + error.statusCode = 401; + return next(error); + } + if (!token.user._id.equals(req.user._id)) { + const error = new Error("CSRF request token user mismatch"); + error.statusCode = 401; + return next(error); + } + } + + await CsrfToken.updateOne( + { _id: token._id }, + { $set: { claimed: new Date() } }, + ); + + return next(); + }; + } + + async create(req: Request, options: CsrfTokenOptions): Promise { + const NOW = new Date(); + + options = Object.assign( + { + expiresMinutes: 30, + }, + options, + ); + + if (options.expiresMinutes > 120) { + const error = new Error("CSRF tokens have a max lifespan of 120 minutes"); + error.statusCode = 400; + throw error; + } + + const token = new CsrfToken(); + token.name = `csrf-token-${options.name}`; + token.createdAt = NOW; + token.expiresAt = dayjs(NOW).add(options.expiresMinutes, "minute").toDate(); + if (req.user) { + token.user = req.user._id; + } + if (req.ip) { + token.ip = req.ip; + } + token.token = uuidv4(); + await token.save(); + + return token.toObject(); + } +} + +export default new CsrfTokenService(); diff --git a/docs/archive/services/host-monitor.ts b/docs/archive/services/host-monitor.ts new file mode 100644 index 0000000..3c67a2d --- /dev/null +++ b/docs/archive/services/host-monitor.ts @@ -0,0 +1,173 @@ +// src/services/host-monitor.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import env from "../config/env.js"; + +import os from "node:os"; +import v8 from "node:v8"; +import { EventEmitter } from "node:events"; + +import { CronJob } from "cron"; + +import HostMonitor, { IHostMonitor } from "@/models/host-monitor.js"; +import { DtpService } from "../lib/service.js"; + +export interface IHostMonitorStats { + memoryUtilization: number; + rss: number; + heapTotal: number; + heapUsed: number; + heapExternal: number; + osTotal: number; + osFree: number; + timestamp: Date; +} + +class HostMonitorService extends DtpService { + private cronJob: CronJob | undefined; + private stats: IHostMonitor; + private eventEmitter: EventEmitter; + + get name(): string { + return "HostMonitorService"; + } + get slug(): string { + return "search"; + } + + constructor() { + super(); + this.stats = this.createMonitor(); + this.stats.hostname = os.hostname(); + this.eventEmitter = new EventEmitter(); + } + + async start(): Promise { + const { heap_size_limit } = v8.getHeapStatistics(); + const limitMB = (heap_size_limit / 1024 / 1024).toFixed(2); + + this.log.info("starting host monitor cron job"); + this.cronJob = new CronJob( + "*/15 * * * * *", + this.onStoreStats.bind(this), + null, + true, + env.timezone, + ); + + this.log.info("service started", { heapLimit: limitMB }); + } + + async stop(): Promise { + if (this.cronJob) { + this.log.info("stopping host monitor cron job"); + this.cronJob.stop(); + delete this.cronJob; + } + + this.log.info("service stopped"); + } + + subagent(bytes: number): void { + this.stats.memory.ai.subagents.count += 1; + this.stats.memory.ai.subagents.bytes += bytes; + } + + fileOperation(bytes: number): void { + this.stats.memory.ai.fileOperations.count += 1; + this.stats.memory.ai.fileOperations.bytes += bytes; + } + + toolCall(bytes: number): void { + this.stats.memory.ai.toolCalls.count += 1; + this.stats.memory.ai.toolCalls.bytes += bytes; + } + + on(event: string, listener: (...args: any[]) => void): void { + this.eventEmitter.on(event, listener); + } + + off(event: string, listener: (...args: any[]) => void): void { + this.eventEmitter.off(event, listener); + } + + async onStoreStats(): Promise { + const NOW = new Date(); + this.stats.timestamp = NOW; + + const usage: NodeJS.MemoryUsage = process.memoryUsage(); + this.stats.memory.rss = usage.rss; + this.stats.memory.v8.heapTotal = usage.heapTotal; + this.stats.memory.v8.heapUsed = usage.heapUsed; + this.stats.memory.v8.heapExternal = usage.external ?? 0; + + const osTotal = os.totalmem(); + const osFree = os.freemem(); + this.stats.memory.os.total = osTotal; + this.stats.memory.os.free = osFree; + + // store stats to db + await this.stats.save(); + + // Calculate memory utilization percentage + const memoryUsed = osTotal - osFree; + const memoryUtilization = Math.round((memoryUsed / osTotal) * 100); + + // Emit stats event for UI updates + const statsData: IHostMonitorStats = { + memoryUtilization, + rss: usage.rss, + heapTotal: usage.heapTotal, + heapUsed: usage.heapUsed, + heapExternal: usage.external ?? 0, + osTotal, + osFree, + timestamp: NOW, + }; + this.eventEmitter.emit("stats", statsData); + + this.stats = this.createMonitor(); + } + + createMonitor(): IHostMonitor { + const monitor = new HostMonitor(); + monitor.hostname = os.hostname(); + + const usage = process.memoryUsage(); + monitor.memory = { + rss: usage.rss, + os: { + total: os.totalmem(), + free: os.freemem(), + }, + v8: { + heapTotal: usage.heapTotal, + heapUsed: usage.heapUsed, + heapExternal: usage.external ?? 0, + }, + logs: { + count: 0, + bytes: 0, + }, + ai: { + fileOperations: { + count: 0, + bytes: 0, + }, + subagents: { + count: 0, + bytes: 0, + }, + toolCalls: { + count: 0, + bytes: 0, + }, + }, + }; + + return monitor; + } +} + +export default new HostMonitorService(); diff --git a/docs/archive/services/project.ts b/docs/archive/services/project.ts new file mode 100644 index 0000000..7f79388 --- /dev/null +++ b/docs/archive/services/project.ts @@ -0,0 +1,138 @@ +// src/services/project.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Project } from "../models/project.js"; +import { DtpService } from "../lib/service.js"; + +export interface IProjectListItem { + id: string; + name: string; + slug: string; + gitUrl?: string; + createdAt: Date; +} + +export interface IProjectCreateInput { + name: string; + slug: string; + gitUrl?: string; +} + +export interface IProjectUpdateInput { + name?: string; + slug?: string; + gitUrl?: string; +} + +export class ProjectService extends DtpService { + get name(): string { + return "ProjectService"; + } + get slug(): string { + return "project"; + } + + async start(): Promise { + this.log.info("service started"); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + async listByUser(userId: string): Promise { + const projects = await Project.find({ user: userId }) + .sort({ createdAt: -1 }) + .lean(); + + return projects.map((p) => ({ + id: p._id.toString(), + name: p.name, + slug: p.slug, + gitUrl: p.gitUrl, + createdAt: p.createdAt, + })); + } + + async getById(projectId: string, userId: string): Promise { + const project = await Project.findOne({ + _id: projectId, + user: userId, + }).lean(); + + if (!project) { + return null; + } + + return { + id: project._id.toString(), + name: project.name, + slug: project.slug, + gitUrl: project.gitUrl, + createdAt: project.createdAt, + }; + } + + async create(userId: string, input: IProjectCreateInput): Promise { + const project = new Project({ + user: userId, + name: input.name, + slug: input.slug, + gitUrl: input.gitUrl, + }); + + await project.save(); + this.log.info("Project created", { projectId: project._id, slug: project.slug }); + + return { + id: project._id.toString(), + name: project.name, + slug: project.slug, + gitUrl: project.gitUrl, + createdAt: project.createdAt, + }; + } + + async update( + projectId: string, + userId: string, + input: IProjectUpdateInput, + ): Promise { + const project = await Project.findOneAndUpdate( + { _id: projectId, user: userId }, + input, + { new: true }, + ).lean(); + + if (!project) { + return null; + } + + this.log.info("Project updated", { projectId: project._id }); + + return { + id: project._id.toString(), + name: project.name, + slug: project.slug, + gitUrl: project.gitUrl, + createdAt: project.createdAt, + }; + } + + async delete(projectId: string, userId: string): Promise { + const result = await Project.deleteOne({ + _id: projectId, + user: userId, + }); + + if (result.deletedCount === 0) { + return false; + } + + this.log.info("Project deleted", { projectId, userId }); + return true; + } +} + +export default new ProjectService(); \ No newline at end of file diff --git a/docs/archive/services/search.ts b/docs/archive/services/search.ts new file mode 100644 index 0000000..916b3bd --- /dev/null +++ b/docs/archive/services/search.ts @@ -0,0 +1,325 @@ +// src/services/search.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { google } from "googleapis"; + +import { DtpService } from "../lib/service.js"; +import User, { IGoogleCseConnection } from "../models/user.js"; + +export interface SearchResult { + title: string; + link: string; + snippet: string; + image?: string; + position?: number; + displayLink?: string; +} + +export interface SearchOptions { + num?: number; + siteSearch?: string; + dateRestrict?: string; + fileType?: string; + safe?: "active" | "off"; + sort?: "relevance" | "date"; + start?: number; +} + +export class SearchServiceError extends Error { + constructor( + public code: string, + message: string, + public details?: any, + ) { + super(message); + this.name = "SearchServiceError"; + } +} + +class SearchService extends DtpService { + get name(): string { + return "SearchService"; + } + get slug(): string { + return "search"; + } + + constructor() { + super(); + } + + async start(): Promise { + this.log.info("service started"); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + /** + * Check if a user has Google CSE credentials configured + */ + async userHasCseConfigured(userId: string): Promise { + try { + const credentials = await this.getUserCseCredentials(userId); + return credentials !== null; + } catch (error) { + this.log.error("Error checking CSE configuration", { userId, error }); + return false; + } + } + + /** + * Get user's CSE credentials from database + * Note: The schema has select: false on apiKey and engineId, + * so we need to explicitly request them + */ + private async getUserCseCredentials( + userId: string, + ): Promise { + const user = await User.findById(userId) + .select("+connections.google.cse.apiKey +connections.google.cse.engineId") + .lean(); + + if (!user) { + throw new SearchServiceError( + "USER_NOT_FOUND", + `User ${userId} not found`, + ); + } + + const cseConfig = user.connections?.google?.cse; + if (!cseConfig || !cseConfig.apiKey || !cseConfig.engineId) { + return null; + } + + return { + apiKey: cseConfig.apiKey, + engineId: cseConfig.engineId, + }; + } + + /** + * Test CSE credentials by performing a simple search + */ + async testCredentials( + apiKey: string, + engineId: string, + ): Promise<{ success: boolean; error?: string }> { + try { + const customSearch = google.customsearch({ + version: "v1", + auth: apiKey, + }); + + // Perform a simple test search + await customSearch.cse.list({ + q: "test", + cx: engineId, + num: 1, + }); + + // If we get here without error, credentials are valid + return { success: true }; + } catch (error: any) { + const errorInfo = this.parseCseError(error); + return { + success: false, + error: errorInfo.message, + }; + } + } + + /** + * Search for a user using their CSE credentials + */ + async searchForUser( + userId: string, + query: string, + options: SearchOptions = {}, + ): Promise { + const credentials = await this.getUserCseCredentials(userId); + + if (!credentials) { + throw new SearchServiceError( + "CSE_NOT_CONFIGURED", + "Google Custom Search is not configured for this user. " + + "Please configure your Google CSE credentials in Account Settings.", + { + recoveryHint: "Press Ctrl+, to open Account Settings", + }, + ); + } + + return this.executeSearch(credentials, query, options); + } + + /** + * Execute search with provided credentials + */ + private async executeSearch( + credentials: IGoogleCseConnection, + query: string, + options: SearchOptions = {}, + ): Promise { + const customSearch = google.customsearch({ + version: "v1", + auth: credentials.apiKey, + }); + + const params: any = { + q: query, + cx: credentials.engineId, + num: options.num || 10, + }; + + if (options.siteSearch) { + params.siteSearch = options.siteSearch; + } + if (options.dateRestrict) { + params.dateRestrict = options.dateRestrict; + } + if (options.fileType) { + params.fileType = options.fileType; + } + if (options.safe) { + params.safe = options.safe; + } + if (options.sort) { + params.sort = options.sort; + } + if (options.start) { + params.start = options.start; + } + + try { + const response = await customSearch.cse.list(params); + const results: SearchResult[] = []; + + if (response.data.items) { + response.data.items.forEach((item: any, index: number) => { + const result: SearchResult = { + title: item.title || "", + link: item.link || "", + snippet: item.snippet || "", + position: item.position || index + 1, + displayLink: item.displayLink || "", + }; + + // Extract thumbnail if available + if (item.pagemap?.cse_thumbnail?.[0]?.src) { + result.image = item.pagemap.cse_thumbnail[0].src; + } + + results.push(result); + }); + } + + return results; + } catch (error: any) { + this.handleCseError(error); + } + } + + /** + * Legacy search method - will be removed after migration + * For now, throws error to force migration to per-user search + */ + async search( + _query: string, + _options: SearchOptions = {}, + ): Promise { + throw new SearchServiceError( + "DEPRECATED_METHOD", + "The search() method is deprecated. Use searchForUser(userId, query, options) instead.", + ); + } + + /** + * Legacy searchWithPagination - will be removed after migration + */ + async searchWithPagination( + _query: string, + _page: number = 1, + _resultsPerPage: number = 10, + ): Promise { + throw new SearchServiceError( + "DEPRECATED_METHOD", + "The searchWithPagination() method is deprecated. Use searchForUser(userId, query, { start: ... }) instead.", + ); + } + + /** + * Parse Google CSE API errors and provide meaningful messages + */ + private parseCseError(error: any): { + code: string; + message: string; + details?: any; + } { + // Handle Google API error structure + if (error.response?.data?.error) { + const apiError = error.response.data.error; + const statusCode = error.response.status; + + switch (statusCode) { + case 401: + return { + code: "UNAUTHORIZED", + message: `401 Unauthorized - Invalid API key. ${apiError.message || ""}`, + details: { statusCode, apiError }, + }; + case 403: + return { + code: "FORBIDDEN", + message: `403 Forbidden - ${apiError.message || "Access denied"}. Check your Engine ID and API key permissions.`, + details: { statusCode, apiError }, + }; + case 429: + const retryAfter = error.response.headers?.["retry-after"]; + return { + code: "RATE_LIMIT_EXCEEDED", + message: `429 Too Many Requests - Rate limit exceeded. ${retryAfter ? `Retry after: ${retryAfter} seconds.` : ""} ${apiError.message || ""}`, + details: { statusCode, apiError, retryAfter }, + }; + default: + return { + code: `HTTP_${statusCode}`, + message: `HTTP ${statusCode} - ${apiError.message || "Unknown error"}`, + details: { statusCode, apiError }, + }; + } + } + + // Handle network errors + if (error.code === "ENOTFOUND") { + return { + code: "NETWORK_ERROR", + message: "Network error - Unable to reach Google API", + details: { code: error.code }, + }; + } + + // Generic error + return { + code: error.code || "UNKNOWN_ERROR", + message: error.message || "An unknown error occurred", + details: error, + }; + } + + /** + * Handle CSE errors and throw appropriate SearchServiceError + */ + private handleCseError(error: any): never { + const errorInfo = this.parseCseError(error); + throw new SearchServiceError( + errorInfo.code, + errorInfo.message, + errorInfo.details, + ); + } +} + +export default new SearchService(); diff --git a/docs/archive/services/session.ts b/docs/archive/services/session.ts new file mode 100644 index 0000000..04d1a47 --- /dev/null +++ b/docs/archive/services/session.ts @@ -0,0 +1,201 @@ +// src/services/session.ts +// Copyright (C) 2026 DTP Technologies, LLC +// All Rights Reserved + +import crypto from "crypto"; +import dayjs from "dayjs"; + +import { DtpService } from "../lib/service.js"; +import Session, { ISession } from "../models/session.js"; +import User from "../models/user.js"; +import ChatSession from "../models/chat-session.js"; + +export interface SessionOptions { + expiresHours: number; + maxInactiveHours: number; +} + +export class SessionService extends DtpService { + get name(): string { + return "SessionService"; + } + get slug(): string { + return "session"; + } + + private defaultExpiresHours = 24; + + constructor() { + super(); + } + + async start(): Promise { + this.log.info("service started"); + // Start session cleanup cron job + this.startCleanupJob(); + } + + async stop(): Promise { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.log.info("service stopped"); + } + + private cleanupInterval: NodeJS.Timeout | null = null; + + private startCleanupJob(): void { + // Clean up expired sessions every hour + this.cleanupInterval = setInterval( + async () => { + try { + const result = await Session.deleteMany({ + expiresAt: { $lt: new Date() }, + }); + if (result.deletedCount > 0) { + this.log.info(`Cleaned up ${result.deletedCount} expired sessions`); + } + } catch (error) { + this.log.error("Session cleanup failed", { error }); + } + }, + 60 * 60 * 1000, + ); // 1 hour + } + + async create( + userId: string, + ipAddress?: string, + userAgent?: string, + options?: SessionOptions, + ): Promise { + const NOW = new Date(); + const expiresHours = options?.expiresHours || this.defaultExpiresHours; + + const session = new Session(); + session.userId = new (await import("mongoose")).Types.ObjectId(userId); + session.token = crypto.randomBytes(64).toString("hex"); + session.createdAt = NOW; + session.expiresAt = dayjs(NOW).add(expiresHours, "hour").toDate(); + session.lastActivityAt = NOW; + session.ipAddress = ipAddress; + session.userAgent = userAgent; + + await session.save(); + this.log.debug("Session created", { userId, sessionId: session._id }); + + return session; + } + + async findById(sessionId: string): Promise { + const session = await Session.findById(sessionId).populate("userId"); + if (!session) { + this.log.debug("Session not found", { sessionId }); + return null; + } + + // Check if session is expired + if (session.expiresAt < new Date()) { + this.log.debug("Session expired", { sessionId: session._id }); + await Session.deleteOne({ _id: session._id }); + return null; + } + + // Update last activity + session.lastActivityAt = new Date(); + await session.save(); + + this.log.debug("Session found", { sessionId: session._id }); + return session; + } + + async findByToken(token: string): Promise { + const session = await Session.findOne({ token }).populate("userId"); + if (!session) { + return null; + } + + // Check if session is expired + if (session.expiresAt < new Date()) { + this.log.debug("Session expired", { sessionId: session._id }); + await Session.deleteOne({ _id: session._id }); + return null; + } + + // Update last activity + session.lastActivityAt = new Date(); + await session.save(); + + return session; + } + + async revoke(token: string): Promise { + const result = await Session.deleteOne({ token }); + if (result.deletedCount > 0) { + this.log.debug("Session revoked", { token }); + return true; + } + return false; + } + + async revokeAllForUser(userId: string): Promise { + const result = await Session.deleteMany({ userId }); + this.log.debug("Revoked all sessions for user", { + userId, + count: result.deletedCount, + }); + return result.deletedCount; + } + + async extend(session: ISession, hours?: number): Promise { + const extendHours = hours || this.defaultExpiresHours; + session.expiresAt = dayjs(session.expiresAt) + .add(extendHours, "hour") + .toDate(); + session.lastActivityAt = new Date(); + await session.save(); + this.log.debug("Session extended", { sessionId: session._id }); + return session; + } + + async getUserFromSession(session: ISession) { + if (!session.userId) { + return null; + } + // userId is already populated from findByToken + return session.userId as unknown as typeof User.prototype; + } + + async listByProject( + projectId: string, + userId: string, + ): Promise< + Array<{ + _id: string; + name: string; + lastMessageAt: Date | null; + turnCount: number; + toolCallCount: number; + createdAt: Date; + }> + > { + const sessions = await ChatSession.find({ + user: userId, + project: projectId, + }) + .sort({ lastMessageAt: -1, createdAt: -1 }) + .lean(); + + return sessions.map((s) => ({ + _id: s._id.toString(), + name: s.name, + lastMessageAt: s.lastMessageAt ?? null, + turnCount: s.stats.turnCount, + toolCallCount: s.stats.toolCallCount, + createdAt: s.createdAt, + })); + } +} + +export default new SessionService(); diff --git a/docs/archive/services/user.ts b/docs/archive/services/user.ts new file mode 100644 index 0000000..4f148b1 --- /dev/null +++ b/docs/archive/services/user.ts @@ -0,0 +1,188 @@ +// src/services/user.ts +// Copyright (C) 2026 DTP Technologies, LLC +// All Rights Reserved + +import crypto from "crypto"; +import { Types } from "mongoose"; + +import { DtpService } from "../lib/service.js"; +import User, { IUser } from "../models/user.js"; +import env from "../config/env.js"; + +export class UserService extends DtpService { + get name(): string { + return "UserService"; + } + get slug(): string { + return "user"; + } + + constructor() { + super(); + } + + async start(): Promise { + this.log.info("service started"); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + /** + * Create a new user account + */ + async create( + username: string, + password: string, + displayName: string, + isAdmin: boolean = false, + ): Promise { + // Validate input + if (!username || !password || !displayName) { + throw new Error("Username, password, and display name are required"); + } + + if (username.length < 3 || username.length > 12) { + throw new Error("Username must be between 3 and 12 characters"); + } + + if (password.length < 8) { + throw new Error("Password must be at least 8 characters"); + } + + if (displayName.length < 3 || displayName.length > 30) { + throw new Error("Display name must be between 3 and 30 characters"); + } + + // Check if user already exists + const existingUser = await User.findOne({ + username_lc: username.toLowerCase(), + }); + + if (existingUser) { + throw new Error("Username already taken"); + } + + // Hash password with salt + const passwordSalt = env.user.passwordSalt; + const passwordHash = crypto + .pbkdf2Sync(password, passwordSalt, 100000, 64, "sha512") + .toString("hex"); + + // Create user + const user = new User(); + user.username = username; + user.username_lc = username.toLowerCase(); + user.password = passwordHash; + user.passwordSalt = passwordSalt; + user.displayName = displayName; + user.flags = { + isAdmin, + isTest: false, + isBanned: false, + }; + user.connections = { + gab: { + social: { apiToken: "" }, + ai: { apiToken: "" }, + }, + ai: { + providerIds: [], + agentProviderId: null, + agentModel: "", + vectorProviderId: null, + vectorModel: "", + utilityProviderId: null, + utilityModel: "", + }, + }; + + await user.save(); + this.log.info("User created", { + userId: user._id, + username: user.username, + }); + + return user; + } + + /** + * Get user by ID + */ + async getUserById(_id: Types.ObjectId): Promise { + const user = await User.findById(_id); + return user; + } + + /** + * Get user by username (case-insensitive) + */ + async getUserByUsername(username: string): Promise { + const user = await User.findOne({ + username_lc: username.toLowerCase(), + }); + return user; + } + + /** + * Get user by ID with sensitive fields (for auth purposes) + */ + async getUserByIdWithCredentials(_id: Types.ObjectId): Promise { + const user = await User.findById(_id).select("+password +passwordSalt"); + return user; + } + + /** + * Get user by username with sensitive fields (for auth purposes) + */ + async getUserByUsernameWithCredentials( + username: string, + ): Promise { + const user = await User.findOne({ + username_lc: username.toLowerCase(), + }).select("+password +passwordSalt"); + return user; + } + + /** + * Verify user password + */ + async verifyPassword(user: IUser, password: string): Promise { + if (!user.password || !user.passwordSalt) { + return false; + } + + const passwordHash = crypto + .pbkdf2Sync(password, user.passwordSalt, 100000, 64, "sha512") + .toString("hex"); + + return passwordHash === user.password; + } + + /** + * Check if user is banned + */ + isUserBanned(user: IUser): boolean { + return user.flags.isBanned; + } + + /** + * Get public user data (safe to expose to client) + */ + getPublicUserData(user: IUser): { + _id: string; + username: string; + displayName: string; + flags: IUser["flags"]; + } { + return { + _id: user._id.toString(), + username: user.username, + displayName: user.displayName, + flags: user.flags, + }; + } +} + +export default new UserService(); diff --git a/docs/archive/services/vector-store.ts b/docs/archive/services/vector-store.ts new file mode 100644 index 0000000..2f8b1a9 --- /dev/null +++ b/docs/archive/services/vector-store.ts @@ -0,0 +1,206 @@ +// src/services/vector-store.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import env from "../config/env.js"; +import assert from "node:assert"; + +import { QdrantClient } from "@qdrant/js-client-rest"; + +import { DtpService } from "../lib/service.js"; +import AiService from "./ai.js"; + +const VECTOR_DIMENSION = 768; //TODO: This should match the dimension of the embeddings from the AI provider + +class VectorStoreService extends DtpService { + private qdrant?: QdrantClient; + + get name(): string { + return "VectorStoreService"; + } + + get slug(): string { + return "vector-store"; + } + + async start(): Promise { + this.log.info("Initializing Qdrant client"); + this.qdrant = new QdrantClient({ + url: env.qdrant.host, + }); + + this.log.info("service started"); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + async ensureCollection(name: string): Promise { + assert(this.qdrant, "Qdrant client not initialized"); + const collections = await this.qdrant.getCollections(); + const exists = collections.collections.some((col) => col.name === name); + + if (!exists) { + await this.qdrant.createCollection(name, { + vectors: { + size: VECTOR_DIMENSION, + distance: "Cosine", + }, + }); + this.log.info("Qdrant collection initialized", { name }); + } else { + this.log.info("Qdrant collection already exists", { name }); + } + } + + private async getEmbedding( + userId: string, + content: string, + ): Promise { + const client = await AiService.getVectorClient(userId); + const model = await AiService.getVectorModel(userId); + const response = await client.embeddings(content, model); + return response.embedding; + } + + async addDocument( + userId: string, + collectionName: string, + id: string, + content: string, + metadata?: any, + ) { + assert(this.qdrant, "Qdrant client not initialized"); + try { + await this.ensureCollection(collectionName); + + const embedding = await this.getEmbedding(userId, content); + + await this.qdrant.upsert(collectionName, { + points: [ + { + id, + vector: embedding, + payload: { + content, + metadata: metadata || {}, + created_at: new Date().toISOString(), + }, + }, + ], + }); + + this.log.info(`Document added to Qdrant`, { + collectionName, + id, + content, + metadata, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.log.error("Error adding document to Qdrant", { + error: errorMessage, + collectionName, + id, + }); + throw error; + } + } + + async search( + userId: string, + collectionName: string, + query: string, + topK: number = 5, + filter?: Record, + ) { + assert(this.qdrant, "Qdrant client not initialized"); + try { + const queryVector = await this.getEmbedding(userId, query); + + const searchOptions: any = { + vector: queryVector, + limit: topK, + with_payload: true, + with_vector: false, + }; + + if (filter) { + searchOptions.filter = { + must: Object.entries(filter).map(([key, value]) => ({ + key, + match: { value }, + })), + }; + } + + const results = await this.qdrant.search(collectionName, searchOptions); + + this.log.debug("Vector search completed", { + collectionName, + query, + resultCount: results.length, + }); + + return results.map((result: any) => ({ + id: result.id, + content: result.payload?.content, + metadata: result.payload?.metadata, + score: result.score, + })); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.log.error("Error searching in Qdrant", { + error: errorMessage, + collectionName, + query, + }); + throw error; + } + } + + async searchWithContext( + userId: string, + collectionName: string, + query: string, + topK: number = 5, + ): Promise { + const results = await this.search( + userId, + collectionName, + query, + topK, + undefined, + ); + return results + .map( + (result) => `Document ID: ${result.id}\nContent: ${result.content}\n`, + ) + .join("\n---\n"); + } + + async removeDocument(collectionName: string, id: string) { + assert(this.qdrant, "Qdrant client not initialized"); + await this.qdrant.delete(collectionName, { + filter: { + must: [ + { + key: "id", + match: { + value: id, + }, + }, + ], + }, + }); + this.log.info("Document removed from Qdrant", { + collectionName, + id, + }); + } +} + +export default new VectorStoreService(); diff --git a/docs/archive/services/web-fetcher.ts b/docs/archive/services/web-fetcher.ts new file mode 100644 index 0000000..bc1ed89 --- /dev/null +++ b/docs/archive/services/web-fetcher.ts @@ -0,0 +1,276 @@ +// src/services/web-fetcher.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import env from "../config/env.js"; + +import { chromium, type Browser, type Page } from "playwright"; +import TurndownService from "turndown"; +import { JSDOM } from "jsdom"; +import { Readability } from "@mozilla/readability"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { DtpService } from "../lib/service.js"; + +export interface FetchResult { + url: string; + title: string; + markdown: string; + lineCount: number; +} + +export class WebFetcherService extends DtpService { + private turndown: TurndownService; + private cacheDir: string; + + get name(): string { + return "WebFetcherService"; + } + + get slug(): string { + return "web-fetcher"; + } + + constructor() { + super(); + this.turndown = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + hr: "---", + }); + + // Remove non-informational elements before conversion + this.turndown.remove([ + "script", + "style", + "noscript", + "nav", + "footer", + "header", + "button", + "input", + "form", + ]); + + // Initialize cache directory + this.cacheDir = path.join(env.projectRoot, ".gadget-cache"); + } + + async start(): Promise { + this.log.info("service started"); + await this.ensureCacheDir(); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + /** + * Generates a cache key from a URL + */ + private getCacheKey(url: string): string { + const hash = crypto.createHash("sha256").update(url).digest("hex"); + return hash; + } + + /** + * Gets the cache file path for a URL + */ + private getCacheFilePath(url: string): string { + const cacheKey = this.getCacheKey(url); + return path.join(this.cacheDir, `${cacheKey}.md`); + } + + /** + * Ensures the cache directory exists + */ + private async ensureCacheDir(): Promise { + await fs.mkdir(this.cacheDir, { recursive: true }); + } + + /** + * Reads content from cache if available + */ + private async readFromCache(url: string): Promise { + try { + const cacheFile = this.getCacheFilePath(url); + const content = await fs.readFile(cacheFile, "utf-8"); + + // Parse the cached file to extract metadata and markdown + const lines = content.split("\n"); + let markdownStartIndex = 0; + + // Skip metadata section (lines starting with ##) + for (let i = 0; i < lines.length; i++) { + if (lines[i]?.startsWith("## URL:")) { + continue; + } else if (lines[i]?.startsWith("## Title:")) { + continue; + } else if (lines[i]?.startsWith("## Fetched:")) { + markdownStartIndex = i + 2; // Skip the blank line after metadata + break; + } + } + + const markdownLines = lines.slice(markdownStartIndex); + const markdown = markdownLines.join("\n"); + const lineCount = markdown.split("\n").length; + + // Extract title from metadata + const titleLine = lines.find((l) => l?.startsWith("## Title:")); + const title = titleLine?.replace("## Title:", "").trim() || "Unknown"; + + return { + url, + title, + markdown, + lineCount, + }; + } catch { + return null; + } + } + + /** + * Writes content to cache + */ + private async writeToCache( + url: string, + title: string, + markdown: string, + ): Promise { + await this.ensureCacheDir(); + + const cacheFile = this.getCacheFilePath(url); + const timestamp = new Date().toISOString(); + + const content = `## URL: ${url} +## Title: ${title} +## Fetched: ${timestamp} + +${markdown}`; + + await fs.writeFile(cacheFile, content, "utf-8"); + } + + /** + * Adds line numbers to markdown text + */ + private addLineNumbers(text: string): string { + return text + .split("\n") + .map((line, i) => `${(i + 1).toString().padStart(4, " ")} | ${line}`) + .join("\n"); + } + + /** + * Fetches a URL and returns line-numbered Markdown + * Uses cache if available, otherwise fetches fresh content + */ + async fetchUrl(url: string, useCache: boolean = true): Promise { + // Try to read from cache first + if (useCache) { + const cached = await this.readFromCache(url); + if (cached) { + return cached; + } + } + + const browser: Browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page: Page = await context.newPage(); + + try { + // 1. Navigate and wait for SPA hydration + await page.goto(url, { waitUntil: "networkidle", timeout: 30000 }); + + const title = await page.title(); + + // 2. Get the full HTML from the browser + const rawHtml = await page.content(); + + // 3. Use JSDOM to create a DOM instance for Readability + const doc = new JSDOM(rawHtml, { url }); + + // 4. Extract the "essential" content using Readability + const reader = new Readability(doc.window.document); + const article = reader.parse(); + + let htmlContent: string; + let extractedTitle: string = title; + + if (article) { + htmlContent = article.content || ""; + extractedTitle = article.title || title; + } else { + // Fallback: extract from
or + htmlContent = await page.evaluate(() => { + const main = document.querySelector("main") || document.body; + return main.innerHTML; + }); + } + + // 5. Convert to Markdown + let markdown = this.turndown.turndown(htmlContent); + + // 6. Apply Line Numbering for Context Offsets + const numberedMarkdown = this.addLineNumbers(markdown); + const lineCount = numberedMarkdown.split("\n").length; + + const result: FetchResult = { + url, + title: extractedTitle, + markdown: numberedMarkdown, + lineCount, + }; + + // 7. Cache the result + if (useCache) { + await this.writeToCache(url, extractedTitle, numberedMarkdown); + } + + return result; + } finally { + await browser.close(); + } + } + + /** + * Fetches content with line range support (like file_read) + */ + async fetchUrlWithRange( + url: string, + startLine: number = 1, + endLine?: number, + useCache: boolean = true, + ): Promise { + const result = await this.fetchUrl(url, useCache); + + if (startLine === 1 && endLine === undefined) { + return result; + } + + const lines = result.markdown.split("\n"); + const startIdx = Math.max(0, startLine - 1); + const endIdx = + endLine !== undefined ? Math.min(endLine, lines.length) : lines.length; + + const selectedLines = lines.slice(startIdx, endIdx); + const numberedLines = selectedLines + .map( + (line, i) => + `${startIdx + i + 1}. ${line.substring(line.indexOf("|") + 2)}`, + ) + .join("\n"); + + return { + ...result, + markdown: numberedLines, + lineCount: selectedLines.length, + }; + } +} + +export default new WebFetcherService(); diff --git a/docs/archive/tools/chat/export.ts b/docs/archive/tools/chat/export.ts new file mode 100644 index 0000000..636822d --- /dev/null +++ b/docs/archive/tools/chat/export.ts @@ -0,0 +1,553 @@ +// src/tools/chat/export.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { ToolDefinition } from "../../lib/ai-client.js"; +import type { IUser } from "../../models/user.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatHistory } from "../../models/chat-history.js"; +import { ChatSession, ChatSessionMode } from "../../models/chat-session.js"; + +type ExportFormat = "json" | "markdown" | "text"; + +interface IChatToolCallParameter { + name: string; + value: string; +} + +interface IChatFileOperation { + type: "read" | "write" | "edit" | "shell"; + path?: string; + diff?: string; + linesAdded?: number; + linesRemoved?: number; + isBinary?: boolean; +} + +interface IChatToolCall { + tool: { + name: string; + callId: string; + parameters: IChatToolCallParameter[]; + }; + response: string; + fileOperation?: IChatFileOperation; + subagentStats?: { + inputTokens: number; + outputTokens: number; + toolCallCount: number; + }; +} + +interface IChatHistoryExport { + createdAt: Date; + prompt: string; + mode: string; + toolCalls: IChatToolCall[]; + fileOperations: IChatFileOperation[]; + response?: { + thinking?: string; + message?: string; + }; + qdrantId?: string; + status: "success" | "failed"; + isSubagent?: boolean; + error?: { + message: string; + stack?: string; + timestamp: Date; + }; + subagentHistory?: IChatHistoryExport[]; +} + +interface RawChatHistoryItem { + createdAt: Date | string; + prompt: string | undefined; + mode: string | undefined; + toolCalls: unknown; + fileOperations: unknown; + response: { thinking?: string; message?: string } | undefined; + qdrantId: string | undefined; + status: "success" | "failed" | undefined; + isSubagent: boolean | undefined; + error?: { message: string; stack?: string; timestamp: Date }; + subagentHistory?: RawChatHistoryItem[]; +} + +export class ChatExportTool extends DtpTool { + get name(): string { + return "ChatExportTool"; + } + get slug(): string { + return "chat-export"; + } + get metadata() { + return { + name: this.definition.function.name || "chat_export", + category: "chat", + tags: ["export", "session", "download", "backup"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "chat_export", + description: + "Export the conversation from the current or specified session to JSON, Markdown, or plain text format. When filename is provided, writes the export to a file in the current working directory. Returns the formatted content and file information.", + parameters: { + type: "object", + properties: { + session_id: { + type: "string", + description: + "Optional: Session ID to export. If omitted, exports the current session.", + }, + format: { + type: "string", + enum: ["json", "markdown", "text"], + description: + "Export format: 'json' for structured data, 'markdown' for readable docs, 'text' for plain text.", + }, + filename: { + type: "string", + description: + "Optional: Filename to write the export to. When provided, the export will be written to a file in the current working directory. The file will be created or overwritten if it exists.", + }, + }, + required: ["format"], + }, + }, + }; + + public async execute( + context: ToolContext, + args: ToolArguments, + ): Promise { + const { session_id, format, filename } = args; + const user = context.session.user as IUser; + const userId = user._id.toHexString(); + const currentSessionId = context.session._id.toHexString(); + + const exportFormat = (format as ExportFormat) || "text"; + if (!["json", "markdown", "text"].includes(exportFormat)) { + return this.error( + "INVALID_PARAMETER", + `Invalid format: ${exportFormat}. Must be 'json', 'markdown', or 'text'.`, + { parameter: "format" }, + ); + } + + const targetSessionId = + (session_id as string | undefined) || currentSessionId; + if (!targetSessionId) { + return this.error( + "MISSING_SESSION", + "No session ID provided and no current session context.", + ); + } + + try { + const session = await ChatSession.findOne({ + _id: targetSessionId, + user: userId, + }); + + if (!session) { + return this.error( + "NOT_FOUND", + `Session not found: ${targetSessionId}`, + { + recoveryHint: + "Verify the session ID exists and belongs to the current user.", + }, + ); + } + + const history = await ChatHistory.find({ + session: targetSessionId, + status: "success", + }) + .sort({ createdAt: 1 }) + .lean(); + + if (history.length === 0) { + const result = { + content: "", + turnCount: 0, + format: exportFormat, + filename: undefined as string | undefined, + }; + + if (filename && typeof filename === "string") { + result.filename = filename; + } + + return this.success( + result, + "No conversation history found in this session.", + ); + } + + const enhancedHistory = await this.enhanceHistoryWithSubagents( + history as RawChatHistoryItem[], + ); + const exported = this.formatExport( + session, + enhancedHistory, + exportFormat, + ); + + let result: { + content: string; + turnCount: number; + format: ExportFormat; + filename?: string; + filePath?: string; + byteCount?: number; + } = { + content: exported, + turnCount: enhancedHistory.length, + format: exportFormat, + }; + + if (filename && typeof filename === "string") { + const filePath = path.resolve(filename); + await fs.writeFile(filePath, exported, "utf-8"); + const byteCount = Buffer.byteLength(exported, "utf-8"); + result.filename = filename; + result.filePath = filePath; + result.byteCount = byteCount; + } + + return this.success( + result, + filename && typeof filename === "string" + ? `Export written to ${filename} (${result.byteCount} bytes)` + : `Exported ${result.turnCount} turns in ${exportFormat} format`, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.log.error("Failed to export session", { + sessionId: targetSessionId, + error: errorMessage, + }); + + return this.error( + "OPERATION_FAILED", + `Failed to export session: ${errorMessage}`, + { + recoveryHint: "Try a different format or check the session exists.", + }, + ); + } + } + + private async enhanceHistoryWithSubagents( + history: RawChatHistoryItem[], + ): Promise { + const enhanced: IChatHistoryExport[] = []; + + for (const item of history) { + const entry: IChatHistoryExport = { + createdAt: new Date(item.createdAt), + prompt: (item.prompt as string) || "", + mode: (item.mode as string) || "", + toolCalls: (item.toolCalls as IChatToolCall[]) || [], + fileOperations: (item.fileOperations as IChatFileOperation[]) || [], + response: item.response, + qdrantId: (item.qdrantId as string) || undefined, + status: (item.status as "success" | "failed") || "success", + isSubagent: (item.isSubagent as boolean) || false, + error: item.error, + }; + + // Handle subagent history recursively + if (item.subagentHistory && Array.isArray(item.subagentHistory)) { + entry.subagentHistory = await this.enhanceHistoryWithSubagents( + item.subagentHistory as RawChatHistoryItem[], + ); + } + + enhanced.push(entry); + } + + return enhanced; + } + + private formatExport( + session: { _id: unknown; name: string; createdAt: Date; type: string }, + history: IChatHistoryExport[], + format: ExportFormat, + ): string { + switch (format) { + case "json": + return this.formatAsJson(session, history); + case "markdown": + return this.formatAsMarkdown(session, history); + case "text": + default: + return this.formatAsText(session, history); + } + } + + private formatAsJson( + session: { _id: unknown; name: string; createdAt: Date; type: string }, + history: IChatHistoryExport[], + ): string { + const data = { + session: { + id: (session._id as any).toString(), + name: session.name, + type: session.type, + createdAt: session.createdAt.toISOString(), + }, + turns: history.map((turn, index) => ({ + turnNumber: index + 1, + timestamp: turn.createdAt.toISOString(), + mode: turn.mode, + user: turn.prompt, + assistant: turn.response?.message || "", + thinking: turn.response?.thinking || undefined, + toolCalls: turn.toolCalls.map((tc) => ({ + tool: { + name: tc.tool?.name || "", + callId: tc.tool?.callId || "", + parameters: tc.tool?.parameters || [], + }, + response: tc.response || "", + fileOperation: tc.fileOperation, + subagentStats: tc.subagentStats, + })), + fileOperations: turn.fileOperations, + status: turn.status, + isSubagent: turn.isSubagent || false, + error: turn.error, + subagentHistory: turn.subagentHistory?.map((sh) => ({ + turnNumber: 0, + timestamp: sh.createdAt.toISOString(), + mode: sh.mode, + user: sh.prompt, + assistant: sh.response?.message || "", + thinking: sh.response?.thinking || undefined, + toolCalls: sh.toolCalls.map((tc) => ({ + tool: { + name: tc.tool?.name || "", + callId: tc.tool?.callId || "", + parameters: tc.tool?.parameters || [], + }, + response: tc.response || "", + fileOperation: tc.fileOperation, + subagentStats: tc.subagentStats, + })), + fileOperations: sh.fileOperations, + status: sh.status, + isSubagent: sh.isSubagent || false, + error: sh.error, + })), + })), + }; + + return JSON.stringify(data, null, 2); + } + + private formatAsMarkdown( + session: { _id: unknown; name: string; createdAt: Date; type: string }, + history: IChatHistoryExport[], + ): string { + let md = `# ${session.name}\n\n`; + md += `**Session ID:** ${(session._id as any).toString()}\n`; + md += `**Type:** ${session.type}\n`; + md += `**Created:** ${session.createdAt.toISOString()}\n`; + md += `**Turns:** ${history.length}\n\n`; + md += `---\n\n`; + + for (const [i, turn] of history.entries()) { + md += `## Turn ${i + 1}\n\n`; + md += `**Timestamp:** ${turn.createdAt.toISOString()}\n`; + md += `**Mode:** ${turn.mode}\n`; + md += `**Status:** ${turn.status}\n`; + md += `**Subagent:** ${turn.isSubagent ? "Yes" : "No"}\n\n`; + md += `### User\n\n${turn.prompt}\n\n`; + + if (turn.response?.thinking) { + md += `### Thinking\n\n${turn.response.thinking}\n\n`; + } + + if (turn.response?.message) { + md += `### Assistant\n\n${turn.response.message}\n\n`; + } + + if (turn.toolCalls && turn.toolCalls.length > 0) { + md += `### Tool Calls\n\n`; + for (const tc of turn.toolCalls) { + md += `#### Tool Call: ${tc.tool?.name || "unknown"}\n\n`; + md += `**Call ID:** ${tc.tool?.callId || ""}\n\n`; + if (tc.tool?.parameters && tc.tool.parameters.length > 0) { + md += `**Parameters:**\n\n`; + for (const p of tc.tool.parameters) { + md += `- ${p.name}: ${p.value}\n`; + } + md += "\n"; + } + if (tc.response) { + md += `**Response:** ${tc.response}\n\n`; + } + if (tc.fileOperation) { + const fo = tc.fileOperation; + md += `**File Operation:** ${fo.type}\n`; + if (fo.path) md += `**Path:** ${fo.path}\n`; + if (fo.linesAdded !== undefined) + md += `**Lines Added:** ${fo.linesAdded}\n`; + if (fo.linesRemoved !== undefined) + md += `**Lines Removed:** ${fo.linesRemoved}\n`; + md += "\n"; + } + } + md += "---\n\n"; + } + + if (turn.fileOperations && turn.fileOperations.length > 0) { + md += `### File Operations\n\n`; + for (const fo of turn.fileOperations) { + md += `#### File Operation: ${fo.type}\n\n`; + if (fo.path) md += `**Path:** ${fo.path}\n`; + if (fo.linesAdded !== undefined) + md += `**Lines Added:** ${fo.linesAdded}\n`; + if (fo.linesRemoved !== undefined) + md += `**Lines Removed:** ${fo.linesRemoved}\n`; + md += "\n---\n\n"; + } + } + + if (turn.subagentHistory && turn.subagentHistory.length > 0) { + md += `### Subagent History\n\n`; + for (const sh of turn.subagentHistory) { + md += `#### Subagent Turn\n\n`; + md += `**Timestamp:** ${sh.createdAt.toISOString()}\n`; + md += `**Mode:** ${sh.mode}\n`; + md += `**User:** ${sh.prompt}\n\n`; + if (sh.response?.message) { + md += `**Assistant:** ${sh.response.message}\n\n`; + } + if (sh.response?.thinking) { + md += `**Thinking:** ${sh.response.thinking}\n\n`; + } + md += "---\n\n"; + } + } + + if (turn.error) { + md += `### Error\n\n`; + md += `**Message:** ${turn.error.message}\n\n`; + if (turn.error.stack) { + md += `**Stack:**\n\`\`\`\n${turn.error.stack}\n\`\`\`\n\n`; + } + md += "---\n\n"; + } + + md += `---\n\n`; + } + + return md; + } + + private formatAsText( + session: { _id: unknown; name: string; createdAt: Date; type: string }, + history: IChatHistoryExport[], + ): string { + let txt = `${session.name}\n`; + txt += `${"=".repeat(session.name.length)}\n\n`; + txt += `Session ID: ${(session._id as any).toString()}\n`; + txt += `Type: ${session.type}\n`; + txt += `Created: ${session.createdAt.toISOString()}\n`; + txt += `Turns: ${history.length}\n\n`; + txt += `${"-".repeat(40)}\n\n`; + + for (const [i, turn] of history.entries()) { + txt += `[Turn ${i + 1}] ${turn.createdAt.toISOString()}\n`; + txt += `Mode: ${turn.mode}\n`; + txt += `Status: ${turn.status}\n`; + txt += `Subagent: ${turn.isSubagent ? "Yes" : "No"}\n\n`; + txt += `USER:\n${turn.prompt}\n\n`; + + if (turn.response?.thinking) { + txt += `THINKING:\n${turn.response.thinking}\n\n`; + } + + if (turn.response?.message) { + txt += `ASSISTANT:\n${turn.response.message}\n\n`; + } + + if (turn.toolCalls && turn.toolCalls.length > 0) { + txt += `TOOL CALLS:\n`; + for (const tc of turn.toolCalls) { + txt += `- Tool: ${tc.tool?.name || "unknown"} (Call ID: ${tc.tool?.callId || ""})\n`; + if (tc.tool?.parameters) { + for (const p of tc.tool.parameters) { + txt += ` - ${p.name}: ${p.value}\n`; + } + } + if (tc.response) { + txt += ` Response: ${tc.response}\n`; + } + if (tc.fileOperation) { + txt += ` File Op: ${tc.fileOperation.type}`; + if (tc.fileOperation.path) txt += ` (${tc.fileOperation.path})`; + txt += "\n"; + } + } + txt += "\n"; + } + + if (turn.fileOperations && turn.fileOperations.length > 0) { + txt += `FILE OPERATIONS:\n`; + for (const fo of turn.fileOperations) { + txt += `- Op: ${fo.type}`; + if (fo.path) txt += ` (${fo.path})`; + txt += "\n"; + } + txt += "\n"; + } + + if (turn.subagentHistory && turn.subagentHistory.length > 0) { + txt += `SUBAGENT HISTORY:\n`; + for (const sh of turn.subagentHistory) { + txt += `- Turn [${sh.createdAt.toISOString()}]: ${sh.prompt.substring(0, 100)}...\n`; + } + txt += "\n"; + } + + if (turn.error) { + txt += `ERROR: ${turn.error.message}\n`; + if (turn.error.stack) { + txt += `Stack:\n${turn.error.stack}\n`; + } + txt += "\n"; + } + + txt += `${"-".repeat(40)}\n\n`; + } + + return txt; + } +} + +export default new ChatExportTool(); diff --git a/docs/archive/tools/chat/history.ts b/docs/archive/tools/chat/history.ts new file mode 100644 index 0000000..4e75943 --- /dev/null +++ b/docs/archive/tools/chat/history.ts @@ -0,0 +1,182 @@ +// src/tools/chat/history.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; +import type { IUser } from "../../models/user.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import VectorStoreService from "../../services/vector-store.js"; +import { ChatSessionMode } from "@/models/chat-session.js"; + +const COLLECTION_NAME = "chat-history"; + +export class ChatHistoryTool extends DtpTool { + get name(): string { + return "ChatHistoryTool"; + } + get slug(): string { + return "chat-history"; + } + get metadata() { + return { + name: this.definition.function.name || "chat_history", + category: "chat", + tags: ["history", "memory", "semantic", "search", "context"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "chat_history", + description: + "Search your conversation history with this user using semantic search. Use this tool to recall past discussions, find relevant context, or reference previous topics. The search uses semantic similarity, so describe what you're looking for in natural language. Returns up to 5 most relevant conversation turns with their content and relevance scores.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: + "A natural language description of what you're looking for in the conversation history. Example: 'discussions about API design' or 'when we talked about database schema'", + }, + session_id: { + type: "string", + description: + "Optional: Limit search to a specific session ID. If omitted, searches across all sessions with this user.", + }, + }, + required: ["query"], + }, + }, + }; + + public async execute( + context: ToolContext, + args: ToolArguments, + ): Promise { + const { query, session_id } = args; + const user = context.session.user as IUser; + const userId = user._id.toHexString(); + + if (!query) { + return JSON.stringify({ + success: false, + error: "MISSING_QUERY", + message: "The 'query' parameter is required.", + hint: 'Provide a natural language description of what you\'re looking for. Example: {"query": "discussions about authentication"}', + }); + } + + if (typeof query !== "string") { + return JSON.stringify({ + success: false, + error: "INVALID_QUERY_TYPE", + message: `The 'query' parameter must be a string, but received ${typeof query}.`, + hint: "Ensure you pass a string value for the query parameter.", + }); + } + + if (query.trim().length < 3) { + return JSON.stringify({ + success: false, + error: "QUERY_TOO_SHORT", + message: + "The query is too short. Please provide at least 3 characters.", + hint: "Use more descriptive terms to get better search results.", + }); + } + + if ( + session_id !== undefined && + session_id !== null && + typeof session_id !== "string" + ) { + return JSON.stringify({ + success: false, + error: "INVALID_SESSION_ID_TYPE", + message: `The 'session_id' parameter must be a string if provided, but received ${typeof session_id}.`, + hint: "Either omit session_id or provide it as a string.", + }); + } + + this.log.debug("Searching chat history", { + userId, + sessionId: session_id, + query: query.trim(), + }); + + try { + const filter: Record = { userId }; + + if ( + session_id && + typeof session_id === "string" && + session_id.trim().length > 0 + ) { + filter.sessionId = session_id.trim(); + } + + const results = await VectorStoreService.search( + userId, + COLLECTION_NAME, + query.trim(), + 5, + filter, + ); + + if (results.length === 0) { + return JSON.stringify({ + success: true, + count: 0, + message: "No relevant history found for your query.", + hint: "Try using different keywords or broader search terms. If this is a new conversation, there may not be any history yet.", + }); + } + + const formattedResults = results.map((r) => ({ + id: r.id, + content: r.content, + relevance: Math.round(r.score * 100) / 100, + })); + + return JSON.stringify({ + success: true, + count: results.length, + results: formattedResults, + message: `Found ${results.length} relevant conversation turn(s).`, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + + this.log.error("Failed to search chat history", { + userId, + query, + error: errorMessage, + stack: errorStack, + }); + + return JSON.stringify({ + success: false, + error: "SEARCH_FAILED", + message: `Failed to search history: ${errorMessage}`, + hint: "This may be a temporary issue. Try again with a simpler query, or proceed without historical context.", + }); + } + } +} + +export default new ChatHistoryTool(); diff --git a/docs/archive/tools/chat/index.ts b/docs/archive/tools/chat/index.ts new file mode 100644 index 0000000..c05fc6d --- /dev/null +++ b/docs/archive/tools/chat/index.ts @@ -0,0 +1,8 @@ +// src/tools/chat/index.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +export { default as ChatHistoryTool } from "./history.js"; +export { default as ChatSummarizeTool } from "./summarize.js"; +export { default as ChatExportTool } from "./export.js"; +export { default as SubagentTool } from "./subagent.js"; diff --git a/docs/archive/tools/chat/subagent.ts b/docs/archive/tools/chat/subagent.ts new file mode 100644 index 0000000..f6db307 --- /dev/null +++ b/docs/archive/tools/chat/subagent.ts @@ -0,0 +1,155 @@ +// src/tools/chat/subagent.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import AgentService from "../../services/agent.js"; +import { ChatSessionMode } from "@/models/chat-session.js"; +import HostMonitorService from "../../services/host-monitor.js"; + +const VALID_AGENT_TYPES = ["explore", "general"] as const; +type AgentType = (typeof VALID_AGENT_TYPES)[number]; + +export class SubagentTool extends DtpTool { + get name(): string { + return "SubagentTool"; + } + get slug(): string { + return "subagent"; + } + get metadata() { + return { + name: this.definition.function.name || "subagent", + category: "chat", + tags: ["subagent", "spawn", "delegate", "explore", "general"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "subagent", + description: + "Spawn a subagent to perform a specific task. The subagent will execute the task and return its result. Use 'explore' agent type for research and information gathering tasks. Use 'general' agent type for general-purpose task execution.", + parameters: { + type: "object", + properties: { + agent_type: { + type: "string", + enum: VALID_AGENT_TYPES, + description: + "The type of subagent to spawn. Use 'explore' for research and information gathering. Use 'general' for general-purpose task execution.", + }, + prompt: { + type: "string", + description: + "The task description and instructions for the subagent. Be specific about what information to find or what task to perform.", + }, + }, + required: ["agent_type", "prompt"], + }, + }, + }; + + public async execute( + context: ToolContext, + args: ToolArguments, + ): Promise { + const { agent_type, prompt } = args; + + if (!agent_type) { + return JSON.stringify({ + success: false, + error: "MISSING_AGENT_TYPE", + message: "The 'agent_type' parameter is required.", + hint: "Specify either 'explore' or 'general' for the agent_type parameter.", + }); + } + + if (!VALID_AGENT_TYPES.includes(agent_type as AgentType)) { + return JSON.stringify({ + success: false, + error: "INVALID_AGENT_TYPE", + message: `Invalid agent_type: '${agent_type}'. Must be one of: ${VALID_AGENT_TYPES.join(", ")}`, + hint: "Use 'explore' for research tasks or 'general' for general tasks.", + }); + } + + if (!prompt) { + return JSON.stringify({ + success: false, + error: "MISSING_PROMPT", + message: "The 'prompt' parameter is required.", + hint: "Provide specific instructions for the subagent to execute.", + }); + } + + if (typeof prompt !== "string") { + return JSON.stringify({ + success: false, + error: "INVALID_PROMPT_TYPE", + message: `The 'prompt' parameter must be a string, but received ${typeof prompt}.`, + hint: "Ensure you pass a string value for the prompt parameter.", + }); + } + + this.log.debug("Spawning subagent", { + agentType: agent_type, + promptLength: prompt.length, + }); + + try { + const result = await AgentService.spawnSubagent( + context.session, + agent_type as AgentType, + prompt, + ); + + const resultJson = JSON.stringify({ + success: true, + data: { + agentType: agent_type, + result: result.response, + historyCount: result.history.length, + historyIds: result.historyIds, + subagentType: agent_type, + subagentPrompt: String(prompt), + subagentStats: result.stats, + }, + }); + const byteCount = Buffer.byteLength(resultJson, "utf-8"); + HostMonitorService.subagent(byteCount); + HostMonitorService.toolCall(byteCount); + return resultJson; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.log.error("Subagent execution failed", { + agentType: agent_type, + error: errorMessage, + }); + + return JSON.stringify({ + success: false, + error: "SUBAGENT_FAILED", + message: errorMessage, + }); + } + } +} + +export default new SubagentTool(); diff --git a/docs/archive/tools/chat/summarize.ts b/docs/archive/tools/chat/summarize.ts new file mode 100644 index 0000000..73f3ab5 --- /dev/null +++ b/docs/archive/tools/chat/summarize.ts @@ -0,0 +1,166 @@ +// src/tools/chat/summarize.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; +import type { IUser } from "../../models/user.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatHistory } from "../../models/chat-history.js"; +import { ChatSession, ChatSessionMode } from "../../models/chat-session.js"; +import AiService from "../../services/ai.js"; + +export class ChatSummarizeTool extends DtpTool { + get name(): string { + return "ChatSummarizeTool"; + } + get slug(): string { + return "chat-summarize"; + } + get metadata() { + return { + name: this.definition.function.name || "chat_summarize", + category: "chat", + tags: ["summarize", "session", "overview", "recap"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "chat_summarize", + description: + "Summarize the conversation in the current or specified session. Returns a concise summary of the key topics, decisions, and outcomes discussed.", + parameters: { + type: "object", + properties: { + session_id: { + type: "string", + description: + "Optional: Session ID to summarize. If omitted, summarizes the current session.", + }, + max_length: { + type: "number", + description: + "Optional: Maximum length of summary in words (default: 100).", + }, + }, + required: [], + }, + }, + }; + + public async execute( + context: ToolContext, + args: ToolArguments, + ): Promise { + const { session_id, max_length = 100 } = args; + const user = context.session.user as IUser; + const userId = user._id.toHexString(); + const currentSessionId = context.session._id.toHexString(); + + const targetSessionId = + (session_id as string | undefined) || currentSessionId; + if (!targetSessionId) { + return this.error( + "MISSING_SESSION", + "No session ID provided and no current session context.", + ); + } + + try { + const session = await ChatSession.findOne({ + _id: targetSessionId, + user: userId, + }); + + if (!session) { + return this.error( + "NOT_FOUND", + `Session not found: ${targetSessionId}`, + { + recoveryHint: + "Verify the session ID exists and belongs to the current user.", + }, + ); + } + + const history = await ChatHistory.find({ + session: targetSessionId, + status: "success", + }) + .sort({ createdAt: 1 }) + .lean(); + + if (history.length === 0) { + return this.success( + { summary: "", turnCount: 0 }, + "No conversation history found in this session.", + ); + } + + const conversationText = history + .map((turn) => { + let text = `User: ${turn.prompt}\n`; + if (turn.response?.message) { + text += `Assistant: ${turn.response.message}`; + } + return text; + }) + .join("\n\n---\n\n"); + + const summaryPrompt = `Summarize the following conversation in approximately ${max_length} words or less. Focus on the main topics discussed, any decisions made, and key outcomes. Be concise and factual. + +Conversation: +${conversationText}`; + + this.log.debug("Generating summary", { + sessionId: targetSessionId, + turnCount: history.length, + maxLength: max_length, + }); + + const utilityClient = await AiService.getUtilityClient(userId); + const utilityModel = await AiService.getUtilityModel(userId); + + const response = await utilityClient.chat( + [{ role: "user", content: summaryPrompt }], + [], + utilityModel, + ); + + const summary = response.content.trim(); + return this.success({ summary }, summary); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.log.error("Failed to summarize session", { + sessionId: targetSessionId, + error: errorMessage, + }); + + return this.error( + "OPERATION_FAILED", + `Failed to generate summary: ${errorMessage}`, + { + recoveryHint: + "Try again with a smaller max_length, or proceed without a summary.", + }, + ); + } + } +} + +export default new ChatSummarizeTool(); diff --git a/docs/archive/tools/file/edit.test.ts b/docs/archive/tools/file/edit.test.ts new file mode 100644 index 0000000..3842096 --- /dev/null +++ b/docs/archive/tools/file/edit.test.ts @@ -0,0 +1,287 @@ +// src/tools/file/edit.test.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { FileEditTool } from "./edit.js"; +import { PROJECT_ROOT } from "../../config/env.js"; + +describe("FileEditTool", () => { + let tool: FileEditTool; + let mockSession: any; + const testFilePath = "test-edit-file.txt"; + const testFileContent = "Hello World\nThis is a test file\nGoodbye World\n"; + + beforeEach(async () => { + tool = new FileEditTool(); + mockSession = { + _id: "test-session-id", + user: "test-user-id", + }; + // Create test file + await fs.writeFile( + path.join(PROJECT_ROOT, testFilePath), + testFileContent, + "utf-8", + ); + }); + + afterEach(async () => { + // Clean up test file + try { + await fs.unlink(path.join(PROJECT_ROOT, testFilePath)); + } catch { + // Ignore if file doesn't exist + } + }); + + describe("definition", () => { + it("should have correct tool name", () => { + expect(tool.definition.function.name).toBe("file_edit"); + }); + + it("should have correct definition structure for AI clients", () => { + const def = tool.definition; + expect(def.type).toBe("function"); + expect(def.function).toBeDefined(); + expect(def.function.name).toBe("file_edit"); + expect(def.function.description).toBeDefined(); + expect(def.function.parameters).toBeDefined(); + const params = def.function.parameters as any; + expect(params.type).toBe("object"); + expect(params.properties).toBeDefined(); + expect(params.properties.path).toBeDefined(); + expect(params.properties.search).toBeDefined(); + expect(params.properties.replace).toBeDefined(); + expect(params.required).toContain("path"); + expect(params.required).toContain("search"); + expect(params.required).toContain("replace"); + }); + }); + + describe("execute", () => { + it("should return error for missing path", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "", search: "test", replace: "test" }, + ); + + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("File path must not be empty"); + }); + + it("should return error for missing search string", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: testFilePath, search: "", replace: "test" }, + ); + + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("Search string must not be empty"); + }); + + it("should return error for undefined replace string", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: testFilePath, search: "test", replace: undefined as any }, + ); + + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("Replace string must not be undefined"); + }); + + it("should return error for non-existent file", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "non/existent/file.txt", search: "test", replace: "test" }, + ); + + expect(result).toContain("NOT_FOUND"); + expect(result).toContain("File not found"); + }); + + it("should return error when search string not found", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: testFilePath, search: "NotFound", replace: "test" }, + ); + + expect(result).toContain("NOT_FOUND"); + expect(result).toContain("Search string not found"); + }); + + it("should show file content context when search not found", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: testFilePath, search: "NotFound", replace: "test" }, + ); + + // Should show file content context + expect(result).toContain("File content"); + expect(result).toContain("line"); + }); + + it("should successfully edit a file and return plain text response", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: testFilePath, search: "World", replace: "Universe" }, + ); + + // Verify plain text format with header + expect(result).toContain("PATH:"); + expect(result).toContain("FILE OPERATION: edit"); + expect(result).toContain("SEARCH FOUND: true"); + expect(result).toContain("---"); + + // Verify the file was actually edited + const fileContent = await fs.readFile( + path.join(PROJECT_ROOT, testFilePath), + "utf-8", + ); + expect(fileContent).toContain("Hello Universe"); + expect(fileContent).not.toContain("Hello World"); + }); + + it("should include diff context in the response", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: testFilePath, search: "World", replace: "Universe" }, + ); + + // Response should contain diff context + expect(result).toContain("Changed line"); + expect(result).toContain("Removed"); + expect(result).toContain("Added"); + }); + + it("should show context before and after the change", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: testFilePath, search: "test", replace: "sample" }, + ); + + // Response should contain context + expect(result).toContain("Context"); + }); + + it("should handle multi-line search and replace", async () => { + const multiLineContent = "Line 1\nLine 2\nLine 3\nLine 4\n"; + await fs.writeFile( + path.join(PROJECT_ROOT, testFilePath), + multiLineContent, + "utf-8", + ); + + const result = await tool.execute( + { session: mockSession }, + { + path: testFilePath, + search: "Line 2\nLine 3", + replace: "Replacement", + }, + ); + + expect(result).toContain("Changed lines"); + expect(result).toContain("Search spanned 2 lines"); + + // Verify the file was edited correctly + const fileContent = await fs.readFile( + path.join(PROJECT_ROOT, testFilePath), + "utf-8", + ); + expect(fileContent).toBe("Line 1\nReplacement\nLine 4\n"); + }); + + it("should show all affected lines for multi-line changes", async () => { + const multiLineContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n"; + await fs.writeFile( + path.join(PROJECT_ROOT, testFilePath), + multiLineContent, + "utf-8", + ); + + const result = await tool.execute( + { session: mockSession }, + { + path: testFilePath, + search: "Line 2\nLine 3\nLine 4", + replace: "New Line 2\nNew Line 3", + }, + ); + + // Should show all changed lines + expect(result).toContain("Changed lines 2-4"); + expect(result).toContain("Search spanned 3 lines"); + expect(result).toContain("Line 2"); + expect(result).toContain("Line 3"); + expect(result).toContain("Line 4"); + }); + + it("should handle multi-line replacement with different line count", async () => { + const content = "Start\nMiddle\nEnd\n"; + await fs.writeFile( + path.join(PROJECT_ROOT, testFilePath), + content, + "utf-8", + ); + + const result = await tool.execute( + { session: mockSession }, + { + path: testFilePath, + search: "Middle", + replace: "New Middle 1\nNew Middle 2\nNew Middle 3", + }, + ); + + expect(result).toContain("Changed line"); + + // Verify the file was edited correctly + const fileContent = await fs.readFile( + path.join(PROJECT_ROOT, testFilePath), + "utf-8", + ); + expect(fileContent).toBe( + "Start\nNew Middle 1\nNew Middle 2\nNew Middle 3\nEnd\n", + ); + }); + }); + + describe("response format for AI clients", () => { + it("should return a string that can be used in message content", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: testFilePath, search: "World", replace: "Universe" }, + ); + + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("should return plain text with header metadata", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: testFilePath, search: "World", replace: "Universe" }, + ); + + // Verify plain text format with header + expect(result).toMatch(/^PATH:/m); + expect(result).toContain("FILE OPERATION: edit"); + expect(result).toContain("SEARCH FOUND: true"); + expect(result).toContain("---"); + }); + + it("should not return JSON format", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: testFilePath, search: "World", replace: "Universe" }, + ); + + // Should not contain JSON structure + expect(result).not.toMatch(/^\{.*"success".*\}$/s); + expect(result).not.toMatch(/^\{.*"data".*\}$/s); + }); + }); +}); diff --git a/docs/archive/tools/file/edit.ts b/docs/archive/tools/file/edit.ts new file mode 100644 index 0000000..2e92c8a --- /dev/null +++ b/docs/archive/tools/file/edit.ts @@ -0,0 +1,439 @@ +// src/tools/file/edit.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatSessionMode } from "@/models/chat-session.js"; +import type { IChatFileOperation } from "../../models/chat-history.js"; +import { validateProjectPath } from "../../lib/path-security.js"; +import { PROJECT_ROOT } from "../../config/env.js"; +import HostMonitorService from "../../services/host-monitor.js"; + +// Binary file extensions that should not be diffed or stored +const BINARY_EXTENSIONS = new Set([ + ".o", + ".obj", + ".a", + ".lib", + ".so", + ".dll", + ".exe", + ".bin", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".ico", + ".webp", + ".pdf", + ".zip", + ".tar", + ".gz", + ".bz2", + ".7z", + ".wasm", + ".pyc", + ".class", +]); + +function isBinaryPath(filePath: string): boolean { + return BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase()); +} + +function countLineDelta( + oldContent: string, + newContent: string, +): { added: number; removed: number } { + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + // Simple unified diff line count approximation + let added = 0; + let removed = 0; + const maxLen = Math.max(oldLines.length, newLines.length); + for (let i = 0; i < maxLen; i++) { + const o = oldLines[i]; + const n = newLines[i]; + if (o === undefined && n !== undefined) { + added++; + } else if (n === undefined && o !== undefined) { + removed++; + } else if (o !== n) { + added++; + removed++; + } + } + return { added, removed }; +} + +function buildUnifiedDiff( + oldContent: string, + newContent: string, + filePath: string, +): string { + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + const lines: string[] = []; + lines.push(`--- a/${filePath}`); + lines.push(`+++ b/${filePath}`); + const maxLen = Math.max(oldLines.length, newLines.length); + for (let i = 0; i < maxLen; i++) { + const o = oldLines[i]; + const n = newLines[i]; + if (o === undefined) { + lines.push(`+${n ?? ""}`); + } else if (n === undefined) { + lines.push(`-${o}`); + } else if (o !== n) { + lines.push(`-${o}`); + lines.push(`+${n}`); + } else { + lines.push(` ${o}`); + } + } + return lines.join("\n"); +} + +export class FileEditTool extends DtpTool { + get name(): string { + return "FileEditTool"; + } + get slug(): string { + return "file-edit"; + } + get metadata() { + return { + name: this.definition.function.name || "file_edit", + category: "file", + tags: ["file", "edit", "replace", "modify", "search"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "file_edit", + description: + "Perform a search-and-replace edit on an existing file. Replaces the first occurrence of the search string with the replace string. Returns a diff context showing what changed.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file to edit (relative or absolute).", + }, + search: { + type: "string", + description: + "The exact text to search for (first occurrence will be replaced).", + }, + replace: { + type: "string", + description: "The text to replace the search string with.", + }, + }, + required: ["path", "search", "replace"], + }, + }, + }; + + public async execute( + _context: ToolContext, + args: ToolArguments, + ): Promise { + const filePath = args.path as string | undefined; + const search = args.search as string | undefined; + const replace = args.replace as string | undefined; + + if (!filePath || filePath.trim().length === 0) { + return this.error("MISSING_PARAMETER", "File path must not be empty.", { + parameter: "path", + recoveryHint: "Provide a valid file path.", + }); + } + + if (search === undefined || search.length === 0) { + return this.error( + "MISSING_PARAMETER", + "Search string must not be empty.", + { + parameter: "search", + recoveryHint: "Provide the exact text to search for.", + }, + ); + } + + if (replace === undefined) { + return this.error( + "MISSING_PARAMETER", + "Replace string must not be undefined.", + { + parameter: "replace", + recoveryHint: + "Provide the replacement text (use empty string to delete).", + }, + ); + } + + // Validate path security - prevent path traversal attacks + const pathValidation = validateProjectPath(filePath, PROJECT_ROOT); + if (!pathValidation.valid || !pathValidation.resolvedPath) { + return this.error( + "SECURITY_VIOLATION", + pathValidation.error || "Invalid path", + { + parameter: "path", + recoveryHint: + "Provide a valid relative path within the project directory.", + }, + ); + } + + const resolvedPath = pathValidation.resolvedPath; + + try { + const content = await fs.readFile(resolvedPath, "utf-8"); + + const searchIdx = content.indexOf(search); + if (searchIdx === -1) { + // Enhancement B: Show file content context on NOT_FOUND + const contextInfo = this.buildNotFoundContext(content, search); + return this.error( + "NOT_FOUND", + `Search string not found in ${filePath}.${contextInfo}`, + { + parameter: "search", + recoveryHint: + "Verify your search string matches exactly, including whitespace and line endings.", + }, + ); + } + + const newContent = content.replace(search, replace); + await fs.writeFile(resolvedPath, newContent, "utf-8"); + + const diffContext = this.buildDiffContext( + content, + searchIdx, + search, + newContent, + ); + + const output = `File edited: ${filePath}\n\n${diffContext}`; + + // Build file operation metadata for the session panel + const binary = isBinaryPath(filePath); + const fileOperation: IChatFileOperation = { + type: "edit", + path: filePath, + isBinary: binary, + }; + if (!binary) { + const delta = countLineDelta(content, newContent); + fileOperation.linesAdded = delta.added; + fileOperation.linesRemoved = delta.removed; + fileOperation.diff = buildUnifiedDiff(content, newContent, filePath); + } + + const byteCount = Buffer.byteLength(output, "utf-8"); + HostMonitorService.fileOperation(byteCount); + HostMonitorService.toolCall(byteCount); + + // Return plain text format instead of JSON + const plainTextResponse = `PATH: ${filePath} +FILE OPERATION: edit +SEARCH FOUND: true +--- +${output}`; + + return plainTextResponse; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + if (errorMessage.includes("ENOENT")) { + return this.error("NOT_FOUND", `File not found: ${filePath}`, { + parameter: "path", + recoveryHint: + "Check the file path and ensure the file exists. Use file_write to create it.", + }); + } + + this.log.error("Failed to edit file", { + path: filePath, + error: errorMessage, + }); + + return this.error( + "OPERATION_FAILED", + `Failed to edit file: ${errorMessage}`, + ); + } + } + + /** + * Build context information when search string is not found. + * Shows file content around the approximate location where the search might have been expected. + */ + private buildNotFoundContext(content: string, search: string): string { + const lines = content.split("\n"); + const searchLower = search.toLowerCase(); + const searchWords = searchLower.split(/\s+/).filter((w) => w.length > 2); + + // Try to find a line that contains some words from the search + let bestMatchLine = -1; + let bestMatchScore = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + const lineLower = line.toLowerCase(); + let score = 0; + for (const word of searchWords) { + if (lineLower.includes(word)) { + score++; + } + } + if (score > bestMatchScore) { + bestMatchScore = score; + bestMatchLine = i; + } + } + + if (bestMatchLine === -1) { + // No similar content found, show first few lines + const previewLines = lines.slice(0, 5); + const preview = previewLines + .map((line, i) => ` ${i + 1}: ${line ?? ""}`) + .join("\n"); + return `\n\nFile content (first 5 lines):\n${preview}`; + } + + // Show context around the best match + const contextStart = Math.max(0, bestMatchLine - 2); + const contextEnd = Math.min(lines.length, bestMatchLine + 3); + const contextLines = lines.slice(contextStart, contextEnd); + const context = contextLines + .map((line, i) => ` ${contextStart + i + 1}: ${line ?? ""}`) + .join("\n"); + + return `\n\nFile content around line ${bestMatchLine + 1} (possible match location):\n${context}`; + } + + private buildDiffContext( + original: string, + matchStart: number, + search: string, + newContent: string, + ): string { + const contextLines = 2; + const oldLines = original.split("\n"); + const newLines = newContent.split("\n"); + + // Find the starting line of the match + let charOffset = 0; + let matchStartLine = 0; + for (let i = 0; i < oldLines.length; i++) { + const line = oldLines[i]; + if (line === undefined) break; + const lineLen = line.length + 1; // +1 for newline + if (charOffset + lineLen > matchStart) { + matchStartLine = i; + break; + } + charOffset += lineLen; + } + + // Calculate how many lines the search string spans + const searchLines = search.split("\n"); + const matchEndLine = matchStartLine + searchLines.length - 1; + + // Enhancement A: Show all affected lines for multi-line changes + const affectedStartLine = Math.max(0, matchStartLine - contextLines); + + const diffLines: string[] = []; + + // Add summary header + const numChangedLines = matchEndLine - matchStartLine + 1; + if (numChangedLines === 1) { + const lineNum = matchStartLine + 1; + const oldLineText = oldLines[matchStartLine] ?? ""; + const newLineText = newLines[matchStartLine] ?? ""; + const oldLen = oldLineText.length; + const newLen = newLineText.length; + + diffLines.push(`Changed line ${lineNum}:`); + diffLines.push(` Removed (${oldLen} chars): ${oldLineText}`); + diffLines.push(` Added (${newLen} chars): ${newLineText}`); + } else { + diffLines.push( + `Changed lines ${matchStartLine + 1}-${matchEndLine + 1}:`, + ); + diffLines.push(` Search spanned ${numChangedLines} lines`); + diffLines.push(` --- Old:`); + for (let i = matchStartLine; i <= matchEndLine; i++) { + const oldLine = oldLines[i]; + if (oldLine !== undefined) { + diffLines.push(` ${i + 1}: ${oldLine}`); + } + } + diffLines.push(` --- New:`); + // For multi-line replacements, show the corresponding new lines + const numReplaceLines = searchLines.length; + for (let i = 0; i < numReplaceLines; i++) { + const newLineIdx = matchStartLine + i; + const newLine = newLines[newLineIdx]; + if (newLine !== undefined) { + diffLines.push(` ${newLineIdx + 1}: ${newLine}`); + } + } + } + + // Add context before (if any) + if (matchStartLine > affectedStartLine) { + diffLines.push(""); + diffLines.push("Context before:"); + for (let i = affectedStartLine; i < matchStartLine; i++) { + const ctxLine = oldLines[i]; + if (ctxLine !== undefined) { + diffLines.push(` ${i + 1}: ${ctxLine}`); + } + } + } + + // Add context after (if any) + const actualEndLine = Math.min( + newLines.length, + matchEndLine + contextLines + 1, + ); + if (matchEndLine + 1 < actualEndLine) { + diffLines.push(""); + diffLines.push("Context after:"); + for (let i = matchEndLine + 1; i < actualEndLine; i++) { + const ctxLine = newLines[i]; + if (ctxLine !== undefined) { + diffLines.push(` ${i + 1}: ${ctxLine}`); + } + } + } + + return diffLines.join("\n"); + } +} + +export default new FileEditTool(); diff --git a/docs/archive/tools/file/fetch-url.ts b/docs/archive/tools/file/fetch-url.ts new file mode 100644 index 0000000..073b396 --- /dev/null +++ b/docs/archive/tools/file/fetch-url.ts @@ -0,0 +1,195 @@ +// src/tools/file/fetch-url.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatSessionMode } from "@/models/chat-session.js"; +import webFetcherService from "../../services/web-fetcher.js"; + +export class FetchUrlTool extends DtpTool { + get name(): string { + return "FetchUrlTool"; + } + get slug(): string { + return "fetch-url"; + } + get metadata() { + return { + name: this.definition.function.name || "fetch_url", + category: "file", + tags: ["fetch", "url", "web", "http", "scrape", "io"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "fetch_url", + description: + "Fetches a URL and returns the content as line-numbered Markdown. Uses Playwright for browser automation, Readability for content extraction, and Turndown for HTML-to-Markdown conversion. Supports line range parameters (startLine, endLine) like file_read. Caches results to .gadget-cache directory.", + parameters: { + type: "object", + properties: { + url: { + type: "string", + description: + "The URL to fetch (must start with http:// or https://).", + }, + startLine: { + type: "number", + description: "Starting line number (1-indexed). Defaults to 1.", + }, + endLine: { + type: "number", + description: + "Ending line number (inclusive). Defaults to end of content.", + }, + useCache: { + type: "boolean", + description: + "Whether to use cached content if available. Defaults to true.", + }, + }, + required: ["url"], + }, + }, + }; + + public async execute( + _context: ToolContext, + args: ToolArguments, + ): Promise { + const url = args.url as string | undefined; + const startLine = (args.startLine as number | undefined) ?? 1; + const endLine = args.endLine as number | undefined; + const useCache = (args.useCache as boolean | undefined) ?? true; + + // Validate URL parameter + if (!url || url.trim().length === 0) { + return this.error("MISSING_PARAMETER", "URL must not be empty.", { + parameter: "url", + recoveryHint: "Provide a valid URL starting with http:// or https://", + }); + } + + // Validate URL format + const urlPattern = /^https?:\/\/.+/i; + if (!urlPattern.test(url)) { + return this.error( + "INVALID_PARAMETER", + "URL must start with http:// or https://", + { + parameter: "url", + expected: "A valid URL starting with http:// or https://", + example: "https://example.com", + }, + ); + } + + // Validate startLine + if (startLine < 1) { + return this.error("INVALID_PARAMETER", "startLine must be >= 1.", { + parameter: "startLine", + expected: "A positive integer >= 1", + }); + } + + // Validate endLine + if (endLine !== undefined && endLine < startLine) { + return this.error("INVALID_PARAMETER", "endLine must be >= startLine.", { + parameter: "endLine", + expected: "An integer >= startLine", + }); + } + + try { + this.log.info("Fetching URL", { url, startLine, endLine, useCache }); + + // Fetch the URL with optional line range + const result = await webFetcherService.fetchUrlWithRange( + url, + startLine, + endLine, + useCache, + ); + + // Format the response similar to file_read + const plainTextResponse = `URL: ${result.url} +TITLE: ${result.title} +TOTAL LINES: ${result.lineCount} +LINES SHOWN: ${result.lineCount} +FETCH OPERATION: fetch_url +--- +${result.markdown}`; + + this.log.info("Successfully fetched URL", { + url, + title: result.title, + lineCount: result.lineCount, + }); + + return plainTextResponse; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.log.error("Failed to fetch URL", { + url, + error: errorMessage, + }); + + if (errorMessage.includes("timeout")) { + return this.error( + "TIMEOUT", + `Request timed out while fetching: ${url}`, + { + recoveryHint: + "The page may be slow to load or the URL may be unreachable.", + }, + ); + } + + if ( + errorMessage.includes("ENOTFOUND") || + errorMessage.includes("net::ERR_NAME_NOT_RESOLVED") + ) { + return this.error( + "OPERATION_FAILED", + `Failed to resolve hostname: ${url}`, + { + recoveryHint: "Check the URL and ensure the domain is accessible.", + }, + ); + } + + if ( + errorMessage.includes("net::ERR_ABORTED") || + errorMessage.includes("404") + ) { + return this.error("NOT_FOUND", `Page not found: ${url}`, { + recoveryHint: + "The URL may be incorrect or the page may have been removed.", + }); + } + + return this.error( + "OPERATION_FAILED", + `Failed to fetch URL: ${errorMessage}`, + ); + } + } +} + +export default new FetchUrlTool(); diff --git a/docs/archive/tools/file/index.ts b/docs/archive/tools/file/index.ts new file mode 100644 index 0000000..fa3a8ca --- /dev/null +++ b/docs/archive/tools/file/index.ts @@ -0,0 +1,9 @@ +// src/tools/file/index.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +export { default as FileReadTool } from "./read.js"; +export { default as FileWriteTool } from "./write.js"; +export { default as FileEditTool } from "./edit.js"; +export { default as ShellExecTool } from "./shell.js"; +export { default as FetchUrlTool } from "./fetch-url.js"; diff --git a/docs/archive/tools/file/read.test.ts b/docs/archive/tools/file/read.test.ts new file mode 100644 index 0000000..e79e126 --- /dev/null +++ b/docs/archive/tools/file/read.test.ts @@ -0,0 +1,129 @@ +// src/tools/file/read.test.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { describe, it, expect, beforeEach } from "vitest"; +import { FileReadTool } from "./read.js"; + +describe("FileReadTool", () => { + let tool: FileReadTool; + let mockSession: any; + + beforeEach(() => { + tool = new FileReadTool(); + mockSession = { + _id: "test-session-id", + user: "test-user-id", + }; + }); + + describe("definition", () => { + it("should have correct tool name", () => { + expect(tool.definition.function.name).toBe("file_read"); + }); + + it("should have correct definition structure for AI clients", () => { + const def = tool.definition; + expect(def.type).toBe("function"); + expect(def.function).toBeDefined(); + expect(def.function.name).toBe("file_read"); + expect(def.function.description).toBeDefined(); + expect(def.function.parameters).toBeDefined(); + const params = def.function.parameters as any; + expect(params.type).toBe("object"); + expect(params.properties).toBeDefined(); + expect(params.properties.path).toBeDefined(); + expect(params.required).toContain("path"); + }); + }); + + describe("execute", () => { + it("should return error for missing path", async () => { + const result = await tool.execute({ session: mockSession }, { path: "" }); + + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("File path must not be empty"); + }); + + it("should return error for non-existent file", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "non/existent/file.txt" }, + ); + + expect(result).toContain("NOT_FOUND"); + expect(result).toContain("File not found"); + }); + + it("should successfully read README.md", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "README.md" }, + ); + + // Result is now plain text with header metadata + expect(result).toContain("PATH: README.md"); + expect(result).toContain("TOTAL LINES:"); + expect(result).toContain("LINES SHOWN:"); + expect(result).toContain("FILE OPERATION: read"); + expect(result).toContain("Gadget Code"); + }); + + it("should read file with line numbers", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "README.md" }, + ); + + // Result is plain text - content is after the "---" separator + expect(result).toContain("Gadget Code"); + + const lines = result.split("\n"); + const firstContentLine = lines.find((l: string) => + l.includes("Gadget Code"), + ); + expect(firstContentLine).toBeDefined(); + if (firstContentLine) { + expect(firstContentLine).toMatch(/^\d+:/); + } + }); + + it("should respect line range parameters", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "README.md", startLine: 1, endLine: 3 }, + ); + + // Result is plain text - check for line range in output + expect(result).toContain("lines 1-3"); + const lines = result.split("\n").filter((l: string) => l.match(/^\d+: /)); + expect(lines.length).toBeLessThanOrEqual(3); + }); + }); + + describe("response format for AI clients", () => { + it("should return a string that can be used in message content", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "README.md" }, + ); + + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("should return plain text with header metadata", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "README.md" }, + ); + + // Verify plain text format with header + expect(result).toMatch(/^PATH:/m); + expect(result).toContain("TOTAL LINES:"); + expect(result).toContain("LINES SHOWN:"); + expect(result).toContain("FILE OPERATION: read"); + expect(result).toContain("---"); + }); + }); +}); diff --git a/docs/archive/tools/file/read.ts b/docs/archive/tools/file/read.ts new file mode 100644 index 0000000..f193044 --- /dev/null +++ b/docs/archive/tools/file/read.ts @@ -0,0 +1,230 @@ +// src/tools/file/read.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import fs from "node:fs/promises"; +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatSessionMode } from "@/models/chat-session.js"; +import { validateProjectPath } from "../../lib/path-security.js"; +import { PROJECT_ROOT } from "../../config/env.js"; +import HostMonitorService from "../../services/host-monitor.js"; + +const MAX_FILE_SIZE = 50 * 1024; // 50KB + +export class FileReadTool extends DtpTool { + get name(): string { + return "FileReadTool"; + } + get slug(): string { + return "file-read"; + } + get metadata() { + return { + name: this.definition.function.name || "file_read", + category: "file", + tags: ["file", "read", "view", "display"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "file_read", + description: + "Read the contents of a file with line numbers. Returns text with numbered lines. Supports reading a specific line range. Binary files cannot be displayed.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file to read (relative or absolute).", + }, + startLine: { + type: "number", + description: "Starting line number (1-indexed). Defaults to 1.", + }, + endLine: { + type: "number", + description: + "Ending line number (inclusive). Defaults to end of file.", + }, + }, + required: ["path"], + }, + }, + }; + + public async execute( + _context: ToolContext, + args: ToolArguments, + ): Promise { + const filePath = args.path as string | undefined; + + if (!filePath || filePath.trim().length === 0) { + return this.error("MISSING_PARAMETER", "File path must not be empty.", { + parameter: "path", + recoveryHint: "Provide a valid file path.", + }); + } + + const startLine = (args.startLine as number | undefined) ?? 1; + const endLine = args.endLine as number | undefined; + + if (startLine < 1) { + return this.error("INVALID_PARAMETER", "startLine must be >= 1.", { + parameter: "startLine", + expected: "A positive integer >= 1", + }); + } + + if (endLine !== undefined && endLine < startLine) { + return this.error("INVALID_PARAMETER", "endLine must be >= startLine.", { + parameter: "endLine", + expected: "An integer >= startLine", + }); + } + + // Validate path security - prevent path traversal attacks + const pathValidation = validateProjectPath(filePath, PROJECT_ROOT); + if (!pathValidation.valid || !pathValidation.resolvedPath) { + return this.error( + "SECURITY_VIOLATION", + pathValidation.error || "Invalid path", + { + parameter: "path", + recoveryHint: + "Provide a valid relative path within the project directory.", + }, + ); + } + + const resolvedPath = pathValidation.resolvedPath; + + try { + const stat = await fs.stat(resolvedPath); + + if (!stat.isFile()) { + return this.error("INVALID_PARAMETER", `"${filePath}" is not a file.`, { + parameter: "path", + recoveryHint: "Provide a path to a regular file, not a directory.", + }); + } + + if (stat.size > MAX_FILE_SIZE) { + return this.error( + "LIMIT_EXCEEDED", + `File is too large to read (${(stat.size / 1024).toFixed(1)}KB). Maximum file size is ${(MAX_FILE_SIZE / 1024).toFixed(0)}KB.`, + { + parameter: "path", + recoveryHint: `Use startLine and endLine to read a smaller portion of the file, or use shell_exec with head/tail commands.`, + }, + ); + } + + const raw = await fs.readFile(resolvedPath); + + if (this.isBinary(raw)) { + const output = `Binary file, cannot display: ${filePath}`; + const byteCount = Buffer.byteLength(output, "utf-8"); + HostMonitorService.fileOperation(byteCount); + HostMonitorService.toolCall(byteCount); + + // Return plain text response for binary files + const plainTextResponse = `PATH: ${filePath} +TOTAL LINES: 0 +LINES SHOWN: 0 +FILE OPERATION: read +--- +${output}`; + + return plainTextResponse; + } + + const content = raw.toString("utf-8"); + const lines = content.split("\n"); + + const startIdx = Math.max(0, startLine - 1); + const endIdx = + endLine !== undefined ? Math.min(endLine, lines.length) : lines.length; + + const selectedLines = lines.slice(startIdx, endIdx); + const numberedLines = selectedLines + .map((line, i) => `${startIdx + i + 1}: ${line}`) + .join("\n"); + + const totalLines = lines.length; + const output = + endLine !== undefined || startLine > 1 + ? `File: ${filePath} (lines ${startIdx + 1}-${endIdx} of ${totalLines})\n\n${numberedLines}` + : `File: ${filePath} (${totalLines} lines)\n\n${numberedLines}`; + + const fileOperation = { + type: "read" as const, + path: filePath, + isBinary: false, + linesAdded: 0, + linesRemoved: 0, + }; + + const byteCount = Buffer.byteLength(output, "utf-8"); + HostMonitorService.fileOperation(byteCount); + HostMonitorService.toolCall(byteCount); + + // Return plain text response instead of JSON + const plainTextResponse = `PATH: ${filePath} +TOTAL LINES: ${totalLines} +LINES SHOWN: ${selectedLines.length} +FILE OPERATION: ${fileOperation.type} +--- +${output}`; + + return plainTextResponse; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + if (errorMessage.includes("ENOENT")) { + return this.error("NOT_FOUND", `File not found: ${filePath}`, { + parameter: "path", + recoveryHint: "Check the file path and ensure the file exists.", + }); + } + + this.log.error("Failed to read file", { + path: filePath, + error: errorMessage, + }); + + return this.error( + "OPERATION_FAILED", + `Failed to read file: ${errorMessage}`, + ); + } + } + + private isBinary(buffer: Buffer): boolean { + const sampleSize = Math.min(buffer.length, 8192); + for (let i = 0; i < sampleSize; i++) { + const byte = buffer[i]; + if (byte === undefined) continue; + if (byte === 0) return true; + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) return true; + } + return false; + } +} + +export default new FileReadTool(); diff --git a/docs/archive/tools/file/shell.test.ts b/docs/archive/tools/file/shell.test.ts new file mode 100644 index 0000000..3b68e7f --- /dev/null +++ b/docs/archive/tools/file/shell.test.ts @@ -0,0 +1,120 @@ +// src/tools/file/shell.test.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { describe, it, expect, beforeEach } from "vitest"; +import ShellExecTool from "./shell.js"; + +describe("ShellExecTool", () => { + const tool = ShellExecTool; + let mockSession: any; + + beforeEach(() => { + mockSession = { + _id: "test-session-id", + user: "test-user-id", + }; + }); + + it("should have correct tool definition", () => { + expect(tool.name).toBe("ShellExecTool"); + expect(tool.slug).toBe("shell-exec"); + expect(tool.definition.type).toBe("function"); + expect(tool.definition.function.name).toBe("shell_exec"); + }); + + it("should have correct metadata", () => { + expect(tool.metadata.name).toBe("shell_exec"); + expect(tool.metadata.category).toBe("file"); + expect(tool.metadata.tags).toContain("shell"); + }); + + it("should return error for empty command", async () => { + const result = await tool.execute( + { session: mockSession }, + { command: "" }, + ); + expect(result).toContain("error"); + expect(result).toContain("MISSING_PARAMETER"); + }); + + it("should return error for whitespace-only command", async () => { + const result = await tool.execute( + { session: mockSession }, + { command: " " }, + ); + expect(result).toContain("error"); + expect(result).toContain("MISSING_PARAMETER"); + }); + + it("should execute simple command successfully", async () => { + const result = await tool.execute( + { session: mockSession }, + { command: "echo hello" }, + ); + expect(result).toContain("COMMAND: echo hello"); + expect(result).toContain("EXIT CODE: 0"); + expect(result).toContain("---[stdout]"); + expect(result).toContain("hello"); + expect(result).toContain("---[stderr]"); + }); + + it("should return plain text format in message field", async () => { + const result = await tool.execute( + { session: mockSession }, + { command: "echo test" }, + ); + // The message field should contain plain text format + expect(result).toContain('"message":'); + // The message should contain plain text headers (not JSON-escaped in the message value) + expect(result).toContain("COMMAND: echo test"); + expect(result).toContain("EXIT CODE: 0"); + }); + + it("should include stderr when command fails", async () => { + const result = await tool.execute( + { session: mockSession }, + { command: "ls /nonexistent/path/that/does/not/exist" }, + ); + expect(result).toContain("COMMAND:"); + expect(result).toContain("---[stderr]"); + }); + + it("should handle command with both stdout and stderr", async () => { + const result = await tool.execute( + { session: mockSession }, + { command: "sh -c 'echo stdout; echo stderr >&2'" }, + ); + expect(result).toContain("---[stdout]"); + expect(result).toContain("---[stderr]"); + expect(result).toContain("stdout"); + expect(result).toContain("stderr"); + }); + + it("should preserve whitespace in output", async () => { + const result = await tool.execute( + { session: mockSession }, + { command: "echo ' spaces '" }, + ); + // The output should preserve the spaces + expect(result).toContain("spaces"); + }); + + it("should show N/A for empty stdout", async () => { + // Commands that produce no stdout + const result = await tool.execute( + { session: mockSession }, + { command: "true" }, + ); + expect(result).toContain("---[stdout]"); + // Should have the marker even if empty + }); + + it("should include exit code in output", async () => { + const result = await tool.execute( + { session: mockSession }, + { command: "echo test" }, + ); + expect(result).toMatch(/EXIT CODE: \d+/); + }); +}); diff --git a/docs/archive/tools/file/shell.ts b/docs/archive/tools/file/shell.ts new file mode 100644 index 0000000..aad0964 --- /dev/null +++ b/docs/archive/tools/file/shell.ts @@ -0,0 +1,199 @@ +// src/tools/file/shell.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatSessionMode } from "@/models/chat-session.js"; +import { validateWorkingDirectory } from "../../lib/path-security.js"; +import { PROJECT_ROOT } from "../../config/env.js"; +import HostMonitorService from "../../services/host-monitor.js"; + +const execAsync = promisify(exec); + +const DEFAULT_TIMEOUT = 30_000; // 30 seconds +const MAX_TIMEOUT = 120_000; // 120 seconds + +export class ShellExecTool extends DtpTool { + get name(): string { + return "ShellExecTool"; + } + get slug(): string { + return "shell-exec"; + } + get metadata() { + return { + name: this.definition.function.name || "shell_exec", + category: "file", + tags: ["shell", "command", "exec", "run", "process"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "shell_exec", + description: + "Execute a shell command and capture its output. Returns stdout, stderr, and exit code. Use with caution — commands run with the same permissions as this process.", + parameters: { + type: "object", + properties: { + command: { + type: "string", + description: "The shell command to execute.", + }, + cwd: { + type: "string", + description: + "Working directory for the command. Defaults to current directory.", + }, + timeout: { + type: "number", + description: `Timeout in milliseconds. Default: ${DEFAULT_TIMEOUT / 1000}s, max: ${MAX_TIMEOUT / 1000}s.`, + }, + }, + required: ["command"], + }, + }, + }; + + public async execute( + _context: ToolContext, + args: ToolArguments, + ): Promise { + const command = args.command as string | undefined; + + if (!command || command.trim().length === 0) { + return this.error("MISSING_PARAMETER", "Command must not be empty.", { + parameter: "command", + recoveryHint: "Provide a valid shell command to execute.", + }); + } + + // Validate cwd if provided - prevent path traversal attacks + let resolvedCwd: string; + if (args.cwd !== undefined) { + const cwdPath = args.cwd as string; + const cwdValidation = validateWorkingDirectory(cwdPath, PROJECT_ROOT); + if (!cwdValidation.valid || !cwdValidation.resolvedPath) { + return this.error( + "SECURITY_VIOLATION", + cwdValidation.error || "Invalid working directory", + { + parameter: "cwd", + recoveryHint: + "Provide a valid relative path within the project directory.", + }, + ); + } + resolvedCwd = cwdValidation.resolvedPath; + } else { + resolvedCwd = process.cwd(); + } + + const timeout = Math.min( + (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT, + MAX_TIMEOUT, + ); + + try { + const { stdout, stderr } = await execAsync(command, { + cwd: resolvedCwd, + timeout, + maxBuffer: 1024 * 1024 * 10, // 10MB + }); + + const output = this.formatOutput(command, stdout, stderr, 0); + const byteCount = Buffer.byteLength(output, "utf-8"); + HostMonitorService.toolCall(byteCount); + return this.success({ command, exitCode: 0 }, output); + } catch (error) { + if (error instanceof Error) { + const nodeError = error as NodeJS.ErrnoException & { + code?: string; + stdout?: string; + stderr?: string; + }; + + if ( + nodeError.code === "ETIMEDOUT" || + nodeError.message.includes("timeout") + ) { + return this.error( + "TIMEOUT", + `Command timed out after ${timeout / 1000}s: ${command}`, + { + parameter: "timeout", + recoveryHint: `Increase the timeout parameter (max ${MAX_TIMEOUT / 1000}s) or break the command into smaller steps.`, + }, + ); + } + + const exitCode = + typeof nodeError.code === "string" + ? parseInt(nodeError.code.replace("ERR_", ""), 10) + : 1; + + const stdout = nodeError.stdout ?? ""; + const stderr = nodeError.stderr ?? nodeError.message; + + const output = this.formatOutput( + command, + stdout, + stderr, + isNaN(exitCode) ? 1 : exitCode, + ); + const byteCount = Buffer.byteLength(output, "utf-8"); + HostMonitorService.toolCall(byteCount); + return this.success( + { command, exitCode: isNaN(exitCode) ? 1 : exitCode }, + output, + ); + } + + this.log.error("Failed to execute command", { + command, + error: String(error), + }); + + return this.error( + "OPERATION_FAILED", + `Failed to execute command: ${String(error)}`, + ); + } + } + + private formatOutput( + command: string, + stdout: string, + stderr: string, + exitCode: number, + ): string { + const parts: string[] = []; + + parts.push(`COMMAND: ${command}`); + parts.push(`EXIT CODE: ${exitCode}`); + parts.push(`---[stdout]`); + parts.push(stdout || "N/A"); + parts.push(`---[stderr]`); + parts.push(stderr || "N/A"); + + return parts.join("\n"); + } +} + +export default new ShellExecTool(); diff --git a/docs/archive/tools/file/write.test.ts b/docs/archive/tools/file/write.test.ts new file mode 100644 index 0000000..86126b1 --- /dev/null +++ b/docs/archive/tools/file/write.test.ts @@ -0,0 +1,154 @@ +// src/tools/file/write.test.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { FileWriteTool } from "./write.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { PROJECT_ROOT } from "../../config/env.js"; + +const tool = new FileWriteTool(); + +describe("FileWriteTool", () => { + const testDir = path.join(PROJECT_ROOT, "test-temp-write"); + const testFile = path.join(testDir, "test.txt"); + + beforeEach(async () => { + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it("should have correct tool definition", () => { + expect(tool.definition.function.name).toBe("file_write"); + expect(tool.definition.function.description).toContain( + "Create a new file or overwrite", + ); + const params = tool.definition.function.parameters as any; + expect(params.required).toEqual(["path", "content"]); + }); + + it("should reject empty path", async () => { + const result = await tool.execute({} as any, { content: "test" }); + expect(result).toContain("error"); + expect(result).toContain("MISSING_PARAMETER"); + }); + + it("should reject undefined content", async () => { + const result = await tool.execute({} as any, { path: "test.txt" }); + expect(result).toContain("error"); + expect(result).toContain("MISSING_PARAMETER"); + }); + + it("should reject path traversal attempts", async () => { + const result = await tool.execute({} as any, { + path: "../../../etc/passwd", + content: "test", + }); + expect(result).toContain("error"); + expect(result).toContain("SECURITY_VIOLATION"); + }); + + it("should create new file and return plain text response", async () => { + const result = await tool.execute({} as any, { + path: "test-temp-write/test.txt", + content: "Hello, World!", + }); + + expect(result).not.toContain('"success"'); + expect(result).toContain("PATH:"); + expect(result).toContain("FILE OPERATION: write"); + expect(result).toContain("CREATED: true"); + expect(result).toContain("BYTES WRITTEN:"); + expect(result).toContain("---"); + expect(result).toContain("File written:"); + }); + + it("should overwrite existing file and return plain text response", async () => { + // Create file first + await fs.writeFile(testFile, "original content"); + + const result = await tool.execute({} as any, { + path: "test-temp-write/test.txt", + content: "new content", + }); + + expect(result).not.toContain('"success"'); + expect(result).toContain("PATH:"); + expect(result).toContain("FILE OPERATION: write"); + expect(result).toContain("CREATED: false"); + expect(result).toContain("BYTES WRITTEN:"); + expect(result).toContain("---"); + }); + + it("should create parent directories automatically", async () => { + const nestedPath = "test-temp-write/nested/deep/file.txt"; + const result = await tool.execute({} as any, { + path: nestedPath, + content: "nested content", + }); + + expect(result).toContain("File written:"); + const exists = await fs + .access(path.join(PROJECT_ROOT, nestedPath)) + .then(() => true) + .catch(() => false); + expect(exists).toBe(true); + }); + + it("should return correct byte count", async () => { + const content = "Hello, World!"; + const result = await tool.execute({} as any, { + path: "test-temp-write/bytecount.txt", + content: content, + }); + + const expectedBytes = Buffer.byteLength(content, "utf-8"); + expect(result).toContain(`BYTES WRITTEN: ${expectedBytes}`); + }); + + it("should handle multiline content", async () => { + const content = "Line 1\nLine 2\nLine 3"; + const result = await tool.execute({} as any, { + path: "test-temp-write/multiline.txt", + content: content, + }); + + expect(result).toContain("File written:"); + const written = await fs.readFile( + path.join(PROJECT_ROOT, "test-temp-write/multiline.txt"), + "utf-8", + ); + expect(written).toBe(content); + }); + + it("should handle empty content", async () => { + const result = await tool.execute({} as any, { + path: "test-temp-write/empty.txt", + content: "", + }); + + expect(result).toContain("BYTES WRITTEN: 0"); + const written = await fs.readFile( + path.join(PROJECT_ROOT, "test-temp-write/empty.txt"), + "utf-8", + ); + expect(written).toBe(""); + }); + + it("should not return JSON format on success", async () => { + const result = await tool.execute({} as any, { + path: "test-temp-write/nojson.txt", + content: "test", + }); + + // Should not contain JSON structure + expect(result).not.toMatch(/^\s*\{/); + expect(result).not.toContain('"success":'); + expect(result).not.toContain('"data":'); + expect(result).not.toContain('"message":'); + }); +}); diff --git a/docs/archive/tools/file/write.ts b/docs/archive/tools/file/write.ts new file mode 100644 index 0000000..c8c8307 --- /dev/null +++ b/docs/archive/tools/file/write.ts @@ -0,0 +1,202 @@ +// src/tools/file/write.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { ToolDefinition } from "../../lib/ai-client.js"; +import { ChatSessionMode } from "@/models/chat-session.js"; +import type { IChatFileOperation } from "../../models/chat-history.js"; + +import { + DtpTool, + ToolMetadata, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { validateProjectPath } from "../../lib/path-security.js"; +import { PROJECT_ROOT } from "../../config/env.js"; +import HostMonitorService from "../../services/host-monitor.js"; + +const BINARY_EXTENSIONS = new Set([ + ".o", + ".obj", + ".a", + ".lib", + ".so", + ".dll", + ".exe", + ".bin", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".ico", + ".webp", + ".pdf", + ".zip", + ".tar", + ".gz", + ".bz2", + ".7z", + ".wasm", + ".pyc", + ".class", +]); + +export class FileWriteTool extends DtpTool { + get name(): string { + return "FileWriteTool"; + } + get slug(): string { + return "file-write"; + } + get metadata(): ToolMetadata { + return { + name: this.definition.function.name || "file_write", + category: "file", + tags: ["file", "write", "create", "save"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "file_write", + description: + "Create a new file or overwrite an existing file with the given content. Parent directories are created automatically.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "Path to the file to create or overwrite (relative or absolute).", + }, + content: { + type: "string", + description: "The content to write to the file.", + }, + }, + required: ["path", "content"], + }, + }, + }; + + public async execute( + _context: ToolContext, + args: ToolArguments, + ): Promise { + const filePath = args.path as string | undefined; + const content = args.content as string | undefined; + + if (!filePath || filePath.trim().length === 0) { + return this.error("MISSING_PARAMETER", "File path must not be empty.", { + parameter: "path", + recoveryHint: "Provide a valid file path.", + }); + } + + if (content === undefined) { + return this.error("MISSING_PARAMETER", "Content must not be undefined.", { + parameter: "content", + recoveryHint: "Provide the content to write to the file.", + }); + } + + // Validate path security - prevent path traversal attacks + const pathValidation = validateProjectPath(filePath, PROJECT_ROOT); + if (!pathValidation.valid || !pathValidation.resolvedPath) { + return this.error( + "SECURITY_VIOLATION", + pathValidation.error || "Invalid path", + { + parameter: "path", + recoveryHint: + "Provide a valid relative path within the project directory.", + }, + ); + } + + const resolvedPath = pathValidation.resolvedPath; + + try { + const dir = path.dirname(resolvedPath); + await fs.mkdir(dir, { recursive: true }); + + // Capture existing content for diff (if file exists) + let oldContent: string | undefined; + let isExisting = false; + try { + oldContent = await fs.readFile(resolvedPath, "utf-8"); + isExisting = true; + } catch { + // new file - no previous content + } + + const contentStr = String(content); + await fs.writeFile(resolvedPath, contentStr, "utf-8"); + + const byteCount = Buffer.byteLength(contentStr, "utf-8"); + const created = !isExisting; + + const binary = BINARY_EXTENSIONS.has( + path.extname(filePath).toLowerCase(), + ); + const fileOperation: IChatFileOperation = { + type: "write", + path: filePath, + isBinary: binary, + }; + if (!binary) { + const newLines = contentStr.split("\n").length; + const oldLines = oldContent ? oldContent.split("\n").length : 0; + fileOperation.linesAdded = isExisting + ? Math.max(0, newLines - oldLines) + : newLines; + fileOperation.linesRemoved = isExisting + ? Math.max(0, oldLines - newLines) + : 0; + } + + HostMonitorService.fileOperation(byteCount); + HostMonitorService.toolCall(byteCount); + + // Build plain text response with metadata header + const outputLines = [ + `PATH: ${filePath}`, + `FILE OPERATION: write`, + `CREATED: ${created ? "true" : "false"}`, + `BYTES WRITTEN: ${byteCount}`, + "---", + `File written: ${filePath} (${byteCount} bytes)`, + ]; + + return outputLines.join("\n"); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.log.error("Failed to write file", { + path: filePath, + error: errorMessage, + }); + + return this.error( + "OPERATION_FAILED", + `Failed to write file: ${errorMessage}`, + ); + } + } +} + +export default new FileWriteTool(); diff --git a/docs/archive/tools/index.ts b/docs/archive/tools/index.ts new file mode 100644 index 0000000..4e33b7c --- /dev/null +++ b/docs/archive/tools/index.ts @@ -0,0 +1,65 @@ +// src/tools/index.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../lib/ai-client.js"; + +import { DtpTool } from "../lib/tool.js"; +import { ChatSessionMode } from "../models/chat-session.js"; +export { DtpTool } from "../lib/tool.js"; +export type { ToolArguments, ToolContext, ToolMetadata } from "../lib/tool.js"; +export type { ToolDefinition } from "../lib/ai-client.js"; + +const tools = new Map(); + +export function registerTool(tool: DtpTool): void { + if (tools.has(tool.slug)) { + // Tool already registered - this is expected during development/testing + } + tools.set(tool.slug, tool); +} + +export function getTool(slug: string): DtpTool | undefined { + return tools.get(slug); +} + +export function getToolByName(name: string): DtpTool | undefined { + for (const tool of tools.values()) { + if (tool.definition.function.name === name) { + return tool; + } + } + return undefined; +} + +export function getAllTools(): DtpTool[] { + return Array.from(tools.values()); +} + +export function getToolDefinitions(): ToolDefinition[] { + return getAllTools().map((t) => t.definition); +} + +export function getToolsBySlugs(slugs: string[]): DtpTool[] { + return slugs + .map((slug) => tools.get(slug)) + .filter((t): t is DtpTool => t !== undefined); +} + +export function getToolsByCategory(category: string): DtpTool[] { + return getAllTools().filter((t) => t.metadata.category === category); +} + +export function getToolsExcludingCategory(category: string): DtpTool[] { + return getAllTools().filter((t) => t.metadata.category !== category); +} + +export function getToolsByMode(mode: ChatSessionMode): DtpTool[] { + return getAllTools().filter((t) => { + const modes = t.metadata.modes; + if (!modes || modes.length === 0) { + return true; + } + return modes.includes(mode); + }); +} diff --git a/docs/archive/tools/memory/index.ts b/docs/archive/tools/memory/index.ts new file mode 100644 index 0000000..8564c1b --- /dev/null +++ b/docs/archive/tools/memory/index.ts @@ -0,0 +1,6 @@ +// src/tools/memory/index.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +export { default as PinAddTool } from "./pin-add.js"; +export { default as PinRemoveTool } from "./pin-remove.js"; diff --git a/docs/archive/tools/memory/pin-add.ts b/docs/archive/tools/memory/pin-add.ts new file mode 100644 index 0000000..2d9faeb --- /dev/null +++ b/docs/archive/tools/memory/pin-add.ts @@ -0,0 +1,124 @@ +// src/tools/memory/pin-add.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatSession, ChatSessionMode } from "../../models/chat-session.js"; + +const MAX_PIN_CHARS = 8192; + +export class PinAddTool extends DtpTool { + get name(): string { + return "PinAddTool"; + } + get slug(): string { + return "pin-add"; + } + get metadata() { + return { + name: this.definition.function.name || "pin_add", + category: "memory", + tags: ["pin", "pinboard", "note", "memory", "context"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "pin_add", + description: + "Add a note (pin) to the session pinboard. Pins are appended to the system prompt and persist across turns. Use this to store important information, preferences, task specs, URLs, or any context you want to keep available. Total pinboard content is limited to 8192 characters.", + parameters: { + type: "object", + properties: { + content: { + type: "string", + description: + "The content of the pin to add. Be concise but include all relevant details. This text will be appended to the system prompt.", + }, + }, + required: ["content"], + }, + }, + }; + + public async execute( + context: ToolContext, + args: ToolArguments, + ): Promise { + const content = args.content as string | undefined; + + if (!content || content.trim().length === 0) { + return this.error("INVALID_PARAMETER", "Pin content must not be empty.", { + parameter: "content", + recoveryHint: "Provide meaningful content for the pin.", + }); + } + + const sessionId = context.session._id.toHexString(); + + try { + const session = await ChatSession.findById(sessionId); + if (!session) { + return this.error("NOT_FOUND", `Session not found: ${sessionId}`); + } + + const currentTotal = session.pins.reduce( + (sum, pin) => sum + pin.content.length, + 0, + ); + + if (currentTotal + content.length > MAX_PIN_CHARS) { + return this.error( + "OPERATION_FAILED", + `Pinboard is full (${currentTotal}/${MAX_PIN_CHARS} characters used). Remove one or more pins with pin_remove to make room before adding new pins.`, + { + recoveryHint: `Current usage: ${currentTotal}/${MAX_PIN_CHARS} characters. New pin requires ${content.length} characters. Remove existing pins first.`, + }, + ); + } + + session.pins.push({ content: content.trim() }); + await session.save(); + + const pinIndex = session.pins.length - 1; + const pinId = session.pins[pinIndex]!._id!.toString(); + + return this.success( + { + pinId, + content: content.trim(), + totalPins: session.pins.length, + totalChars: currentTotal + content.trim().length, + maxChars: MAX_PIN_CHARS, + }, + `Pin added to pinboard (${session.pins.length} pins, ${currentTotal + content.trim().length}/${MAX_PIN_CHARS} characters used).`, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.log.error("Failed to add pin", { sessionId, error: errorMessage }); + + return this.error( + "OPERATION_FAILED", + `Failed to add pin: ${errorMessage}`, + ); + } + } +} + +export default new PinAddTool(); diff --git a/docs/archive/tools/memory/pin-remove.ts b/docs/archive/tools/memory/pin-remove.ts new file mode 100644 index 0000000..74369ce --- /dev/null +++ b/docs/archive/tools/memory/pin-remove.ts @@ -0,0 +1,128 @@ +// src/tools/memory/pin-remove.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatSession, ChatSessionMode } from "../../models/chat-session.js"; + +export class PinRemoveTool extends DtpTool { + get name(): string { + return "PinRemoveTool"; + } + get slug(): string { + return "pin-remove"; + } + get metadata() { + return { + name: this.definition.function.name || "pin_remove", + category: "memory", + tags: ["pin", "pinboard", "note", "memory", "context", "remove"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "pin_remove", + description: + "Remove a pin from the session pinboard by its ID. Use this to free up space on the pinboard or remove information that is no longer relevant.", + parameters: { + type: "object", + properties: { + pin_id: { + type: "string", + description: + "The _id of the pin to remove. This is returned when the pin was added via pin_add, and is also visible in the PINBOARD section of the system prompt.", + }, + }, + required: ["pin_id"], + }, + }, + }; + + public async execute( + context: ToolContext, + args: ToolArguments, + ): Promise { + const pinId = args.pin_id as string | undefined; + + if (!pinId || pinId.trim().length === 0) { + return this.error("INVALID_PARAMETER", "pin_id must not be empty.", { + parameter: "pin_id", + recoveryHint: "Provide the _id of the pin you want to remove.", + }); + } + + const sessionId = context.session._id.toHexString(); + + try { + const session = await ChatSession.findById(sessionId); + if (!session) { + return this.error("NOT_FOUND", `Session not found: ${sessionId}`); + } + + const pinIndex = session.pins.findIndex( + (pin) => pin._id?.toString() === pinId, + ); + + if (pinIndex === -1) { + return this.error( + "NOT_FOUND", + `Pin not found: ${pinId}. Check the pin ID and try again.`, + { + recoveryHint: + "Pin IDs are shown in the PINBOARD section of the system prompt and returned when pins are added.", + }, + ); + } + + const removedContent = session.pins[pinIndex]!.content; + session.pins.splice(pinIndex, 1); + await session.save(); + + const totalChars = session.pins.reduce( + (sum, pin) => sum + pin.content.length, + 0, + ); + + return this.success( + { + pinId, + content: removedContent, + totalPins: session.pins.length, + totalChars, + maxChars: 8192, + }, + `Pin removed from pinboard (${session.pins.length} pins remaining, ${totalChars}/8192 characters used).`, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.log.error("Failed to remove pin", { + sessionId, + error: errorMessage, + }); + + return this.error( + "OPERATION_FAILED", + `Failed to remove pin: ${errorMessage}`, + ); + } + } +} + +export default new PinRemoveTool(); diff --git a/docs/archive/tools/search/glob.test.ts b/docs/archive/tools/search/glob.test.ts new file mode 100644 index 0000000..787b1a2 --- /dev/null +++ b/docs/archive/tools/search/glob.test.ts @@ -0,0 +1,61 @@ +// src/tools/search/glob.test.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { describe, it, expect, beforeEach } from "vitest"; +import GlobTool from "./glob.js"; + +describe("GlobTool", () => { + let tool: typeof GlobTool; + let mockSession: any; + + beforeEach(() => { + tool = GlobTool; + mockSession = { + _id: "test-session-id", + user: "test-user-id", + }; + }); + + describe("definition", () => { + it("should have correct tool name", () => { + expect(tool.definition.function.name).toBe("glob"); + }); + + it("should have correct category", () => { + expect(tool.metadata.category).toBe("search"); + }); + + it("should support all modes", () => { + expect(tool.metadata.modes).toHaveLength(5); + }); + }); + + describe("execute", () => { + it("should return error for missing pattern", async () => { + const result = await tool.execute({ session: mockSession }, {}); + + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("pattern is required"); + }); + + it("should find TypeScript files", async () => { + const result = await tool.execute( + { session: mockSession }, + { pattern: "**/*.ts" }, + ); + + expect(result).toContain("Found"); + expect(result).toContain(".ts"); + }); + + it("should respect root parameter", async () => { + const result = await tool.execute( + { session: mockSession }, + { pattern: "*.ts", root: "src" }, + ); + + expect(result).toContain("Found"); + }); + }); +}); diff --git a/docs/archive/tools/search/glob.ts b/docs/archive/tools/search/glob.ts new file mode 100644 index 0000000..f850c59 --- /dev/null +++ b/docs/archive/tools/search/glob.ts @@ -0,0 +1,208 @@ +// src/tools/search/glob.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatSessionMode } from "../../models/chat-session.js"; +import HostMonitorService from "../../services/host-monitor.js"; + +const MAX_RESULTS = 1000; + +class GlobTool extends DtpTool { + get name(): string { + return "GlobTool"; + } + get slug(): string { + return "glob"; + } + get metadata() { + return { + name: this.definition.function.name || "glob", + category: "search", + tags: ["search", "find", "file", "pattern", "glob"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "glob", + description: + "Find files by name using pattern matching. Supports glob patterns like **/*.ts, *.js, src/**/*.tsx. Returns a list of matching file paths.", + parameters: { + type: "object", + properties: { + pattern: { + type: "string", + description: + "Glob pattern to match files (e.g., '**/*.ts', 'src/**/*.tsx', '*.json').", + }, + root: { + type: "string", + description: + "Root directory to search from. Defaults to current working directory.", + }, + }, + required: ["pattern"], + }, + }, + }; + + public async execute( + _context: ToolContext, + args: ToolArguments, + ): Promise { + const pattern = args.pattern as string | undefined; + const root = (args.root as string | undefined) || process.cwd(); + + if (!pattern || pattern.trim().length === 0) { + return this.error("MISSING_PARAMETER", "pattern is required.", { + parameter: "pattern", + recoveryHint: + "Provide a glob pattern like '**/*.ts' or 'src/**/*.tsx'.", + }); + } + + try { + const matches = await this.globMatch(pattern, root); + const limited = matches.slice(0, MAX_RESULTS); + + if (matches.length > MAX_RESULTS) { + const output = `Found ${matches.length} files matching "${pattern}" (showing first ${MAX_RESULTS}):\n\n${limited.join("\n")}\n\n... and ${matches.length - MAX_RESULTS} more files`; + const byteCount = Buffer.byteLength(output, "utf-8"); + HostMonitorService.toolCall(byteCount); + return this.success( + { + files: limited, + total: matches.length, + truncated: true, + }, + output, + ); + } + + const output = `Found ${matches.length} file(s) matching "${pattern}":\n\n${matches.join("\n")}`; + const byteCount = Buffer.byteLength(output, "utf-8"); + HostMonitorService.toolCall(byteCount); + return this.success( + { + files: matches, + total: matches.length, + truncated: false, + }, + output, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return this.error( + "OPERATION_FAILED", + `Failed to search: ${errorMessage}`, + ); + } + } + + private async globMatch(pattern: string, root: string): Promise { + const results: string[] = []; + const normalizedRoot = path.resolve(root); + + const parts = pattern.split("/"); + let isRecursive = false; + let searchPattern = pattern; + + if (parts[0] === "**") { + isRecursive = true; + searchPattern = parts.slice(1).join("/"); + } + + const regexPattern = this.globToRegex(searchPattern); + + await this.recurseDir( + normalizedRoot, + regexPattern, + isRecursive, + results, + 0, + 20, + ); + + return results.sort(); + } + + private globToRegex(pattern: string): RegExp { + let regexStr = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + + regexStr = "^" + regexStr + "$"; + + return new RegExp(regexStr); + } + + private async recurseDir( + dir: string, + pattern: RegExp, + recursive: boolean, + results: string[], + depth: number, + maxDepth: number, + ): Promise { + if (depth > maxDepth) return; + + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = path.relative(process.cwd(), fullPath); + + if (entry.isFile()) { + if (pattern.test(entry.name)) { + results.push(relativePath); + } + } else if (entry.isDirectory()) { + if ( + entry.name !== "node_modules" && + entry.name !== ".git" && + entry.name !== "dist" && + entry.name !== "build" + ) { + if (recursive) { + await this.recurseDir( + fullPath, + pattern, + recursive, + results, + depth + 1, + maxDepth, + ); + } else if (pattern.test(entry.name)) { + results.push(relativePath + "/"); + } + } + } + } + } +} + +export default new GlobTool(); diff --git a/docs/archive/tools/search/google.ts b/docs/archive/tools/search/google.ts new file mode 100644 index 0000000..58deaa2 --- /dev/null +++ b/docs/archive/tools/search/google.ts @@ -0,0 +1,224 @@ +// src/tools/search/google-search.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import SearchService, { SearchServiceError } from "../../services/search.js"; + +import type { + ToolArguments, + ToolContext, + ToolMetadata, +} from "../../lib/tool.js"; +import { DtpTool } from "../../lib/tool.js"; +import { ChatSessionMode } from "@/models/chat-session.js"; +import HostMonitorService from "../../services/host-monitor.js"; + +class GoogleSearchTool extends DtpTool { + get name(): string { + return "GoogleSearchTool"; + } + get slug(): string { + return "google-search"; + } + get metadata(): ToolMetadata { + return { + name: this.definition.function.name || "search_google", + category: "search", + tags: ["web", "external", "google"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "search_google", + description: + "Perform a Google search for relevant information on the web.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "The search query string.", + }, + num_results: { + type: "number", + description: + "Number of search results to return (default: 10, max: 10).", + }, + siteSearch: { + type: "string", + description: + "Optional site to restrict the search to (e.g. github.com).", + }, + dateRestrict: { + type: "string", + description: + "Restricts results to documents based on a date range. Examples: d1 (last day), d7 (last week), d30 (last month), d365 (last year).", + }, + fileType: { + type: "string", + description: + "Restricts results to files of a specified extension. Examples: pdf, doc, xls, ppt.", + }, + sort: { + type: "string", + description: + "Sort order for results. Values: 'relevance' (default) or 'date'.", + enum: ["relevance", "date"], + }, + start: { + type: "number", + description: + "The index of the first result to return (for pagination). Default: 1.", + }, + }, + required: ["query"], + }, + }, + }; + + public requiresUserConfig(): boolean { + return true; + } + + public async checkUserConfig(userId: string): Promise { + return await SearchService.userHasCseConfigured(userId); + } + + public async execute( + context: ToolContext, + args: ToolArguments, + ): Promise { + const { query } = args; + + if (!query || typeof query !== "string" || query.trim().length === 0) { + return this.error( + "MISSING_PARAMETER", + "The 'query' parameter is required.", + { + parameter: "query", + expected: "A non-empty string containing the search query.", + example: 'search_google(query: "latest AI news")', + recoveryHint: + "Provide a 'query' parameter with your search terms and try again.", + }, + ); + } + + // Get user ID from context + const userId = context.session.user._id.toString(); + + this.log.debug("performing Google search for user", { userId, args }); + + try { + const { + num_results = 10, + siteSearch, + dateRestrict, + fileType, + sort, + start, + } = args; + + const results = await SearchService.searchForUser(userId, query, { + num: Math.min(num_results as number, 10), + siteSearch: siteSearch as string | undefined, + dateRestrict: dateRestrict as string | undefined, + fileType: fileType as string | undefined, + safe: "active", + sort: sort as "relevance" | "date" | undefined, + start: start as number | undefined, + }); + + this.log.debug("Google search results", { results }); + + let content = ""; + if (results && results.length) { + content += `Here are some relevant search results I found:\n\n`; + for (const result of results) { + const title = JSON.stringify(result.title || "").slice(1, -1); + const link = JSON.stringify(result.link || "").slice(1, -1); + const snippet = JSON.stringify(result.snippet || "").slice(1, -1); + const displayLink = result.displayLink + ? JSON.stringify(result.displayLink).slice(1, -1) + : ""; + + content += `Title: ${title}\n`; + content += `Link: ${link}\n`; + if (displayLink) { + content += `Source: ${displayLink}\n`; + } + content += `Snippet: ${snippet}\n\n`; + } + } else { + content += "No relevant search results found."; + } + + const byteCount = Buffer.byteLength(content, "utf-8"); + HostMonitorService.toolCall(byteCount); + return this.success( + { query, resultCount: results?.length ?? 0 }, + content, + ); + } catch (error: any) { + // Handle SearchServiceError with detailed error information + if (error instanceof SearchServiceError) { + const errorDetails = error.details + ? `\nDetails: ${JSON.stringify(error.details, null, 2)}` + : ""; + + // Map SearchServiceError codes to ToolErrorCode + let toolErrorCode: import("../../lib/tool-error.js").ToolErrorCode = + "OPERATION_FAILED"; + let recoveryHint: string | undefined; + + switch (error.code) { + case "CSE_NOT_CONFIGURED": + toolErrorCode = "PERMISSION_DENIED"; + recoveryHint = + "Press Ctrl+, to open Account Settings and configure your Google CSE credentials."; + break; + case "UNAUTHORIZED": + case "FORBIDDEN": + toolErrorCode = "PERMISSION_DENIED"; + recoveryHint = + "Your Google CSE credentials may be invalid. Press Ctrl+, to update them in Account Settings."; + break; + case "RATE_LIMIT_EXCEEDED": + toolErrorCode = "RATE_LIMITED"; + break; + case "NETWORK_ERROR": + toolErrorCode = "OPERATION_FAILED"; + break; + default: + toolErrorCode = "OPERATION_FAILED"; + } + + return this.error(toolErrorCode, `${error.message}${errorDetails}`, { + recoveryHint, + }); + } + + // Generic error handling + return this.error( + "OPERATION_FAILED", + `Failed to perform search: ${error.message}`, + { + recoveryHint: "Please try again or check your search query.", + }, + ); + } + } +} + +export default new GoogleSearchTool(); diff --git a/docs/archive/tools/search/grep.test.ts b/docs/archive/tools/search/grep.test.ts new file mode 100644 index 0000000..19339df --- /dev/null +++ b/docs/archive/tools/search/grep.test.ts @@ -0,0 +1,83 @@ +// src/tools/search/grep.test.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { describe, it, expect, beforeEach } from "vitest"; +import GrepTool from "./grep.js"; + +describe("GrepTool", () => { + let tool: typeof GrepTool; + let mockSession: any; + + beforeEach(() => { + tool = GrepTool; + mockSession = { + _id: "test-session-id", + user: "test-user-id", + }; + }); + + describe("definition", () => { + it("should have correct tool name", () => { + expect(tool.definition.function.name).toBe("grep"); + }); + + it("should have correct category", () => { + expect(tool.metadata.category).toBe("search"); + }); + + it("should support all modes", () => { + expect(tool.metadata.modes).toHaveLength(5); + }); + }); + + describe("execute", () => { + it("should return error for missing pattern", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "src" }, + ); + + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("pattern is required"); + }); + + it("should return error for missing path", async () => { + const result = await tool.execute( + { session: mockSession }, + { pattern: "test" }, + ); + + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("path is required"); + }); + + it("should find matches in files", async () => { + const result = await tool.execute( + { session: mockSession }, + { pattern: "function", path: "src" }, + ); + + expect(result).toContain("match"); + }); + + it("should support case insensitive search", async () => { + const result = await tool.execute( + { session: mockSession }, + { pattern: "FUNCTION", path: "src", caseInsensitive: true }, + ); + + expect(result).toContain("match"); + }); + + it("should return error for invalid regex", async () => { + const result = await tool.execute( + { session: mockSession }, + { pattern: "[invalid", path: "src" }, + ); + + expect(result).toContain("INVALID_PARAMETER"); + expect(result).toContain("Invalid regex"); + }); + }); +}); diff --git a/docs/archive/tools/search/grep.ts b/docs/archive/tools/search/grep.ts new file mode 100644 index 0000000..659b221 --- /dev/null +++ b/docs/archive/tools/search/grep.ts @@ -0,0 +1,293 @@ +// src/tools/search/grep.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatSessionMode } from "../../models/chat-session.js"; +import HostMonitorService from "../../services/host-monitor.js"; + +const MAX_MATCHES = 500; +const MAX_FILE_SIZE = 1024 * 1024; // 1MB + +class GrepTool extends DtpTool { + get name(): string { + return "GrepTool"; + } + get slug(): string { + return "grep"; + } + get metadata() { + return { + name: this.definition.function.name || "grep", + category: "search", + tags: ["search", "find", "regex", "content", "grep"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "grep", + description: + "Search file contents using regular expressions. Returns matching lines with file paths and line numbers. Supports case-insensitive matching and context lines.", + parameters: { + type: "object", + properties: { + pattern: { + type: "string", + description: + "Regular expression pattern to search for. Use standard JavaScript regex syntax.", + }, + path: { + type: "string", + description: + "File or directory path to search in. Can be a specific file or directory.", + }, + caseInsensitive: { + type: "boolean", + description: "Case insensitive search (default: false).", + }, + contextBefore: { + type: "number", + description: + "Number of lines to show before each match (default: 0).", + }, + contextAfter: { + type: "number", + description: + "Number of lines to show after each match (default: 0).", + }, + }, + required: ["pattern", "path"], + }, + }, + }; + + public async execute( + _context: ToolContext, + args: ToolArguments, + ): Promise { + const pattern = args.pattern as string | undefined; + const searchPath = args.path as string | undefined; + const caseInsensitive = (args.caseInsensitive as boolean) || false; + const contextBefore = (args.contextBefore as number) || 0; + const contextAfter = (args.contextAfter as number) || 0; + + if (!pattern || pattern.trim().length === 0) { + return this.error("MISSING_PARAMETER", "pattern is required.", { + parameter: "pattern", + recoveryHint: "Provide a regex pattern to search for.", + }); + } + + if (!searchPath || searchPath.trim().length === 0) { + return this.error("MISSING_PARAMETER", "path is required.", { + parameter: "path", + recoveryHint: "Provide a file or directory path to search in.", + }); + } + + let regex: RegExp; + try { + regex = new RegExp(pattern, caseInsensitive ? "i" : ""); + } catch (error) { + return this.error( + "INVALID_PARAMETER", + `Invalid regex pattern: ${error instanceof Error ? error.message : String(error)}`, + { parameter: "pattern" }, + ); + } + + try { + const stat = await fs.stat(searchPath); + const matches: Array<{ + file: string; + line: number; + content: string; + }> = []; + + if (stat.isFile()) { + const fileMatches = await this.searchFile( + searchPath, + regex, + contextBefore, + contextAfter, + ); + matches.push(...fileMatches); + } else if (stat.isDirectory()) { + await this.searchDirectory( + searchPath, + regex, + matches, + contextBefore, + contextAfter, + ); + } + + const limited = matches.slice(0, MAX_MATCHES); + + let output = ""; + if (matches.length === 0) { + output = `No matches found for "${pattern}" in ${searchPath}`; + } else { + const truncated = matches.length > MAX_MATCHES; + output = `Found ${matches.length} match(es) for "${pattern}" in ${searchPath}${ + truncated ? ` (showing first ${MAX_MATCHES})` : "" + }:\n\n`; + + for (const match of limited) { + output += `${match.file}:${match.line}: ${match.content}\n`; + } + + if (truncated) { + output += `\n... and ${matches.length - MAX_MATCHES} more matches`; + } + } + + const byteCount = Buffer.byteLength(output, "utf-8"); + HostMonitorService.toolCall(byteCount); + return this.success( + { + matches: limited, + total: matches.length, + truncated: matches.length > MAX_MATCHES, + }, + output, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return this.error( + "OPERATION_FAILED", + `Failed to search: ${errorMessage}`, + ); + } + } + + private async searchFile( + filePath: string, + regex: RegExp, + contextBefore: number, + contextAfter: number, + ): Promise> { + const matches: Array<{ file: string; line: number; content: string }> = []; + + let stat; + try { + stat = await fs.stat(filePath); + } catch { + return matches; + } + + if (stat.size > MAX_FILE_SIZE) { + return matches; + } + + let content: string; + try { + content = await fs.readFile(filePath, "utf-8"); + } catch { + return matches; + } + + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line !== undefined && regex.test(line)) { + const startLine = Math.max(0, i - contextBefore); + const endLine = Math.min(lines.length - 1, i + contextAfter); + + for (let j = startLine; j <= endLine; j++) { + const contextLine = lines[j]; + if (contextLine !== undefined) { + matches.push({ + file: filePath, + line: j + 1, + content: contextLine, + }); + } + } + regex.lastIndex = 0; + } + } + + return matches; + } + + private async searchDirectory( + dir: string, + regex: RegExp, + matches: Array<{ file: string; line: number; content: string }>, + contextBefore: number, + contextAfter: number, + ): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if ( + entry.name !== "node_modules" && + entry.name !== ".git" && + entry.name !== "dist" && + entry.name !== "build" + ) { + await this.searchDirectory( + fullPath, + regex, + matches, + contextBefore, + contextAfter, + ); + } + } else if (entry.isFile()) { + const ext = path.extname(entry.name); + if ( + ext === ".ts" || + ext === ".tsx" || + ext === ".js" || + ext === ".jsx" || + ext === ".json" || + ext === ".md" || + ext === ".txt" + ) { + const fileMatches = await this.searchFile( + fullPath, + regex, + contextBefore, + contextAfter, + ); + matches.push(...fileMatches); + } + } + + if (matches.length >= MAX_MATCHES * 2) { + return; + } + } + } +} + +export default new GrepTool(); diff --git a/docs/archive/tools/search/index.ts b/docs/archive/tools/search/index.ts new file mode 100644 index 0000000..98bc827 --- /dev/null +++ b/docs/archive/tools/search/index.ts @@ -0,0 +1,20 @@ +// src/tools/search/index.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { registerTool } from "../index.js"; + +import GoogleSearchTool from "./google.js"; +import GlobTool from "./glob.js"; +import GrepTool from "./grep.js"; +import ListTool from "./list.js"; + +export { default as GoogleSearchTool } from "./google.js"; +export { default as GlobTool } from "./glob.js"; +export { default as GrepTool } from "./grep.js"; +export { default as ListTool } from "./list.js"; + +registerTool(GoogleSearchTool); +registerTool(GlobTool); +registerTool(GrepTool); +registerTool(ListTool); diff --git a/docs/archive/tools/search/list.test.ts b/docs/archive/tools/search/list.test.ts new file mode 100644 index 0000000..e3b86ff --- /dev/null +++ b/docs/archive/tools/search/list.test.ts @@ -0,0 +1,79 @@ +// src/tools/search/list.test.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { describe, it, expect, beforeEach } from "vitest"; +import ListTool from "./list.js"; + +describe("ListTool", () => { + let tool: typeof ListTool; + let mockSession: any; + + beforeEach(() => { + tool = ListTool; + mockSession = { + _id: "test-session-id", + user: "test-user-id", + }; + }); + + describe("definition", () => { + it("should have correct tool name", () => { + expect(tool.definition.function.name).toBe("list"); + }); + + it("should have correct category", () => { + expect(tool.metadata.category).toBe("search"); + }); + + it("should support all modes", () => { + expect(tool.metadata.modes).toHaveLength(5); + }); + }); + + describe("execute", () => { + it("should list current directory by default", async () => { + const result = await tool.execute({ session: mockSession }, {}); + + expect(result).toContain("Contents of"); + }); + + it("should list specific directory", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "src" }, + ); + + expect(result).toContain("Contents of"); + expect(result).toContain("src"); + }); + + it("should return error for non-directory path", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "package.json" }, + ); + + expect(result).toContain("INVALID_PARAMETER"); + expect(result).toContain("not a directory"); + }); + + it("should support recursive listing", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: "src", recursive: true, maxDepth: 2 }, + ); + + expect(result).toContain("Contents of"); + }); + + it("should show hidden files when requested", async () => { + const result = await tool.execute( + { session: mockSession }, + { path: ".", showHidden: true }, + ); + + expect(result).toContain("Contents of"); + }); + }); +}); diff --git a/docs/archive/tools/search/list.ts b/docs/archive/tools/search/list.ts new file mode 100644 index 0000000..ac15354 --- /dev/null +++ b/docs/archive/tools/search/list.ts @@ -0,0 +1,268 @@ +// src/tools/search/list.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { ChatSessionMode } from "../../models/chat-session.js"; +import HostMonitorService from "../../services/host-monitor.js"; + +const MAX_ENTRIES = 1000; + +class ListTool extends DtpTool { + get name(): string { + return "ListTool"; + } + get slug(): string { + return "list"; + } + get metadata() { + return { + name: this.definition.function.name || "list", + category: "search", + tags: ["search", "find", "directory", "ls", "list"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "list", + description: + "List directory contents with optional filtering. Can show file types, sizes, and modification dates. Supports filtering by glob pattern.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "Directory path to list. Defaults to current working directory.", + }, + pattern: { + type: "string", + description: + "Optional glob pattern to filter results (e.g., '*.ts', 'src/**/*').", + }, + recursive: { + type: "boolean", + description: "List subdirectories recursively (default: false).", + }, + showHidden: { + type: "boolean", + description: + "Show hidden files (files starting with dot) (default: false).", + }, + maxDepth: { + type: "number", + description: + "Maximum directory depth for recursive listing (default: 3).", + }, + }, + required: [], + }, + }, + }; + + public async execute( + _context: ToolContext, + args: ToolArguments, + ): Promise { + const targetPath = (args.path as string | undefined) || process.cwd(); + const patternStr = args.pattern as string | undefined; + let pattern: RegExp | undefined; + if (patternStr) { + try { + const globPattern = patternStr + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + pattern = new RegExp("^" + globPattern + "$"); + } catch { + // Invalid regex, ignore + } + } + const recursive = (args.recursive as boolean) || false; + const showHidden = (args.showHidden as boolean) || false; + const maxDepth = (args.maxDepth as number) || 3; + + let resolvedPath: string; + try { + resolvedPath = path.resolve(targetPath); + } catch { + return this.error("INVALID_PARAMETER", "Invalid path.", { + parameter: "path", + }); + } + + try { + const stat = await fs.stat(resolvedPath); + + if (!stat.isDirectory()) { + return this.error( + "INVALID_PARAMETER", + `"${targetPath}" is not a directory.`, + { + parameter: "path", + recoveryHint: "Provide a directory path to list.", + }, + ); + } + + const entries = await this.listDirectory( + resolvedPath, + pattern || undefined, + recursive, + showHidden, + 0, + maxDepth, + ); + + const limited = entries.slice(0, MAX_ENTRIES); + + let output = ""; + if (entries.length === 0) { + output = `No entries found in "${targetPath}"`; + } else { + const truncated = entries.length > MAX_ENTRIES; + output = `Contents of "${targetPath}" (${ + truncated ? `showing first ${MAX_ENTRIES} of ` : "" + }${entries.length} entries):\n\n`; + + for (const entry of limited) { + const typeIndicator = entry.isDirectory + ? "d" + : entry.isSymlink + ? "l" + : "-"; + const size = entry.isDirectory ? "-" : entry.size.toString(); + const modified = entry.modified + ? new Date(entry.modified).toISOString().split("T")[0] + : "-"; + output += `${typeIndicator} ${size.padStart(10)} ${modified} ${entry.name}\n`; + } + + if (truncated) { + output += `\n... and ${entries.length - MAX_ENTRIES} more entries`; + } + } + + const byteCount = Buffer.byteLength(output, "utf-8"); + HostMonitorService.toolCall(byteCount); + return this.success( + { + entries: limited, + total: entries.length, + truncated: entries.length > MAX_ENTRIES, + }, + output, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return this.error( + "OPERATION_FAILED", + `Failed to list directory: ${errorMessage}`, + ); + } + } + + private async listDirectory( + dir: string, + pattern: RegExp | undefined, + recursive: boolean, + showHidden: boolean, + depth: number, + maxDepth: number, + ): Promise< + Array<{ + name: string; + isDirectory: boolean; + isSymlink: boolean; + size: number; + modified: Date | null; + }> + > { + const results: Array<{ + name: string; + isDirectory: boolean; + isSymlink: boolean; + size: number; + modified: Date | null; + }> = []; + + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return results; + } + + for (const entry of entries) { + if (!showHidden && entry.name.startsWith(".")) { + continue; + } + + if (entry.name === "node_modules" || entry.name === ".git") { + continue; + } + + const fullPath = path.join(dir, entry.name); + let relativePath = fullPath; + + try { + relativePath = path.relative(process.cwd(), fullPath); + } catch { + // ignore + } + + if (pattern && !pattern.test(relativePath) && !pattern.test(entry.name)) { + continue; + } + + let stat; + try { + stat = await fs.stat(fullPath); + } catch { + continue; + } + + results.push({ + name: relativePath, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink(), + size: stat.size, + modified: stat.mtime, + }); + + if (recursive && entry.isDirectory() && depth < maxDepth) { + const subResults = await this.listDirectory( + fullPath, + pattern, + recursive, + showHidden, + depth + 1, + maxDepth, + ); + results.push(...subResults); + } + } + + return results; + } +} + +export default new ListTool(); diff --git a/docs/archive/tools/setup.ts b/docs/archive/tools/setup.ts new file mode 100644 index 0000000..0c87871 --- /dev/null +++ b/docs/archive/tools/setup.ts @@ -0,0 +1,74 @@ +// src/tools/setup.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { registerTool } from "./index.js"; + +// Chat tools +import { + ChatHistoryTool, + ChatSummarizeTool, + ChatExportTool, + SubagentTool, +} from "./chat/index.js"; + +// Search tools +import { GoogleSearchTool } from "./search/index.js"; + +// Memory tools +import { PinAddTool, PinRemoveTool } from "./memory/index.js"; + +// File tools +import { + FileReadTool, + FileWriteTool, + FileEditTool, + ShellExecTool, +} from "./file/index.js"; + +// Question tools - TODO: reimplement +// import { AskQuestionsTool } from "./question/index.js"; + +// Skills tools +import { + GetSkill, + SearchSkills, + CreateSkill, + UpdateSkill, +} from "./skills/index.js"; + +// File tools (URL fetching) +import FetchUrlTool from "./file/fetch-url.js"; + +export async function setupTools(): Promise { + // Chat tools + registerTool(ChatHistoryTool); + registerTool(ChatSummarizeTool); + registerTool(ChatExportTool); + registerTool(SubagentTool); + + // Search tools + registerTool(GoogleSearchTool); + + // Memory tools + registerTool(PinAddTool); + registerTool(PinRemoveTool); + + // File tools + registerTool(FileReadTool); + registerTool(FileWriteTool); + registerTool(FileEditTool); + registerTool(ShellExecTool); + + // Question tools + // registerTool(AskQuestionsTool); + + // Skills tools + registerTool(GetSkill); + registerTool(SearchSkills); + registerTool(CreateSkill); + registerTool(UpdateSkill); + + // File tools (URL fetching) + registerTool(FetchUrlTool); +} diff --git a/docs/archive/tools/skills/create-skill.ts b/docs/archive/tools/skills/create-skill.ts new file mode 100644 index 0000000..44a877b --- /dev/null +++ b/docs/archive/tools/skills/create-skill.ts @@ -0,0 +1,158 @@ +// src/tools/skills/create-skill.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { AiSkill } from "../../models/ai-skill.js"; +import { ChatSessionMode } from "../../models/chat-session.js"; +import type { IUser } from "../../models/user.js"; + +export class CreateSkillTool extends DtpTool { + get name(): string { + return "CreateSkillTool"; + } + get slug(): string { + return "create-skill"; + } + get metadata() { + return { + name: this.definition.function.name || "create_skill", + category: "skills", + tags: ["skill", "create", "new", "add"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "create_skill", + description: + "Create a new skill. Skills are recipes or instruction manuals the agent can follow. Provide a clear name, description, tags for searchability, and the content with detailed instructions.", + parameters: { + type: "object", + properties: { + name: { + type: "string", + description: + "The name of the skill (e.g., 'Write React Component').", + }, + description: { + type: "string", + description: + "A brief description of what this skill does and when to use it.", + }, + tags: { + type: "string", + description: + "Comma-separated list of tags for searchability (e.g., 'react, frontend, component').", + }, + modes: { + type: "array", + items: { + type: "string", + enum: ["build", "plan", "test", "ship", "dev"], + }, + description: + "Array of modes where this skill can be used. Defaults to ['build'] if not specified.", + }, + content: { + type: "string", + description: + "The full content/instructions of the skill. This is what the agent will read when using this skill.", + }, + }, + required: ["name", "description", "content"], + }, + }, + }; + + public async execute( + context: ToolContext, + args: ToolArguments, + ): Promise { + const name = args.name as string | undefined; + const description = args.description as string | undefined; + const tagsStr = args.tags as string | undefined; + const modesArg = args.modes as string[] | undefined; + const content = args.content as string | undefined; + + if (!name || !description || !content) { + return this.error( + "INVALID_PARAMETER", + "name, description, and content are required.", + { parameter: "name, description, content" }, + ); + } + + const tags = tagsStr + ? tagsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0) + : []; + + let modes: ChatSessionMode[] = [ChatSessionMode.Build]; + if (modesArg && Array.isArray(modesArg)) { + modes = modesArg + .map((m) => m.toLowerCase()) + .filter((m) => ["build", "plan", "test", "ship", "dev"].includes(m)) + .map((m) => { + switch (m) { + case "build": + return ChatSessionMode.Build; + case "plan": + return ChatSessionMode.Plan; + case "test": + return ChatSessionMode.Test; + case "ship": + return ChatSessionMode.Ship; + case "dev": + return ChatSessionMode.Develop; + default: + return ChatSessionMode.Build; + } + }); + if (modes.length === 0) modes = [ChatSessionMode.Build]; + } + + try { + const user = context.session.user as IUser; + const skill = new AiSkill({ + user: user._id, + name, + description, + tags, + modes, + content, + }); + + await skill.save(); + return this.success( + { skillId: skill._id?.toString(), name }, + `Skill created: ${name}`, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return this.error( + "OPERATION_FAILED", + `Failed to create skill: ${errorMessage}`, + ); + } + } +} + +export default new CreateSkillTool(); diff --git a/docs/archive/tools/skills/get-skill.ts b/docs/archive/tools/skills/get-skill.ts new file mode 100644 index 0000000..5bd5fe3 --- /dev/null +++ b/docs/archive/tools/skills/get-skill.ts @@ -0,0 +1,85 @@ +// src/tools/skills/get-skill.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { AiSkill } from "../../models/ai-skill.js"; +import { ChatSessionMode } from "../../models/chat-session.js"; + +export class GetSkillTool extends DtpTool { + get name(): string { + return "GetSkillTool"; + } + get slug(): string { + return "get-skill"; + } + get metadata() { + return { + name: this.definition.function.name || "get_skill", + category: "skills", + tags: ["skill", "knowledge", "recipe", "get"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "get_skill", + description: + "Fetch a specific skill by its ID. Returns the skill's name, description, tags, modes, and content. Use this when you know the skill ID or need to retrieve a specific skill's full details.", + parameters: { + type: "object", + properties: { + skill_id: { + type: "string", + description: "The ID of the skill to fetch (MongoDB ObjectId).", + }, + }, + required: ["skill_id"], + }, + }, + }; + + public async execute( + _context: ToolContext, + args: ToolArguments, + ): Promise { + const skillId = args.skill_id as string | undefined; + + if (!skillId) { + return this.error("INVALID_PARAMETER", "skill_id is required.", { + parameter: "skill_id", + }); + } + + try { + const skill = await AiSkill.findById(skillId).lean(); + if (!skill) { + return this.error("NOT_FOUND", `Skill not found: ${skillId}`); + } + return this.success({ skill }, `Skill: ${skill.name}`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return this.error( + "OPERATION_FAILED", + `Failed to get skill: ${errorMessage}`, + ); + } + } +} + +export default new GetSkillTool(); diff --git a/docs/archive/tools/skills/index.ts b/docs/archive/tools/skills/index.ts new file mode 100644 index 0000000..9b3c8e0 --- /dev/null +++ b/docs/archive/tools/skills/index.ts @@ -0,0 +1,22 @@ +// src/tools/skills/index.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { registerTool } from "../index.js"; + +import GetSkillTool from "./get-skill.js"; +import SearchSkillsTool from "./search-skills.js"; +import CreateSkillTool from "./create-skill.js"; +import UpdateSkillTool from "./update-skill.js"; + +registerTool(GetSkillTool); +registerTool(SearchSkillsTool); +registerTool(CreateSkillTool); +registerTool(UpdateSkillTool); + +export const GetSkill = GetSkillTool; +export const SearchSkills = SearchSkillsTool; +export const CreateSkill = CreateSkillTool; +export const UpdateSkill = UpdateSkillTool; + +export { GetSkillTool, SearchSkillsTool, CreateSkillTool, UpdateSkillTool }; diff --git a/docs/archive/tools/skills/search-skills.ts b/docs/archive/tools/skills/search-skills.ts new file mode 100644 index 0000000..6207fa7 --- /dev/null +++ b/docs/archive/tools/skills/search-skills.ts @@ -0,0 +1,108 @@ +// src/tools/skills/search-skills.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { AiSkill } from "../../models/ai-skill.js"; +import { ChatSessionMode } from "../../models/chat-session.js"; + +export class SearchSkillsTool extends DtpTool { + get name(): string { + return "SearchSkillsTool"; + } + get slug(): string { + return "search-skills"; + } + get metadata() { + return { + name: this.definition.function.name || "search_skills", + category: "skills", + tags: ["skill", "search", "find", "query"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "search_skills", + description: + "Search for skills using text search. Matches against skill names, descriptions, and tags. Returns a list of matching skills with their basic info. Use this to find relevant skills when you need guidance on a specific task, then fetch the skill(s) you need by ID to read their full content.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: + "The search query. Use keywords that match skill names, descriptions, or tags.", + }, + }, + required: ["query"], + }, + }, + }; + + public async execute( + _context: ToolContext, + args: ToolArguments, + ): Promise { + const query = args.query as string | undefined; + + if (!query || query.trim().length === 0) { + return this.error("INVALID_PARAMETER", "query is required.", { + parameter: "query", + }); + } + + try { + const skills = await AiSkill.find( + { $text: { $search: query } }, + { score: { $meta: "textScore" } }, + ) + .sort({ score: { $meta: "textScore" } }) + .limit(10) + .lean(); + + if (skills.length === 0) { + return this.success( + { skills: [], total: 0 }, + `No skills found matching "${query}".`, + ); + } + + const results = skills.map((s) => ({ + skillId: s._id.toString(), + name: s.name, + description: s.description, + tags: s.tags, + modes: s.modes, + isGlobal: s.user === null, + })); + return this.success( + { results, total: results.length }, + `Found ${results.length} skill(s).`, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return this.error( + "OPERATION_FAILED", + `Failed to search skills: ${errorMessage}`, + ); + } + } +} + +export default new SearchSkillsTool(); diff --git a/docs/archive/tools/skills/update-skill.ts b/docs/archive/tools/skills/update-skill.ts new file mode 100644 index 0000000..17268de --- /dev/null +++ b/docs/archive/tools/skills/update-skill.ts @@ -0,0 +1,170 @@ +// src/tools/skills/update-skill.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import type { ToolDefinition } from "../../lib/ai-client.js"; + +import { + DtpTool, + type ToolArguments, + type ToolContext, +} from "../../lib/tool.js"; +import { AiSkill } from "../../models/ai-skill.js"; +import { ChatSessionMode } from "../../models/chat-session.js"; +import type { IUser } from "../../models/user.js"; + +export class UpdateSkillTool extends DtpTool { + get name(): string { + return "UpdateSkillTool"; + } + get slug(): string { + return "update-skill"; + } + get metadata() { + return { + name: this.definition.function.name || "update_skill", + category: "skills", + tags: ["skill", "update", "edit", "modify"], + modes: [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ], + }; + } + + public definition: ToolDefinition = { + type: "function", + function: { + name: "update_skill", + description: + "Update an existing skill. You must know the skill ID. You can update any combination of name, description, tags, modes, and content. Only provide the fields you want to change.", + parameters: { + type: "object", + properties: { + skill_id: { + type: "string", + description: "The ID of the skill to update.", + }, + name: { + type: "string", + description: "New name for the skill (optional).", + }, + description: { + type: "string", + description: "New description (optional).", + }, + tags: { + type: "string", + description: + "New comma-separated tags (optional, replaces existing).", + }, + modes: { + type: "array", + items: { + type: "string", + enum: ["build", "plan", "test", "ship", "dev"], + }, + description: "New array of modes (optional, replaces existing).", + }, + content: { + type: "string", + description: "New content/instructions (optional).", + }, + }, + required: ["skill_id"], + }, + }, + }; + + public async execute( + context: ToolContext, + args: ToolArguments, + ): Promise { + const skillId = args.skill_id as string | undefined; + const name = args.name as string | undefined; + const description = args.description as string | undefined; + const tagsStr = args.tags as string | undefined; + const modesArg = args.modes as string[] | undefined; + const content = args.content as string | undefined; + + if (!skillId) { + return this.error("INVALID_PARAMETER", "skill_id is required.", { + parameter: "skill_id", + }); + } + + try { + const skill = await AiSkill.findById(skillId).lean(); + if (!skill) { + return this.error("NOT_FOUND", `Skill not found: ${skillId}`); + } + + const user = context.session.user as IUser; + const userId = user._id.toString(); + const isOwner = skill.user?.toString() === userId; + const isGlobal = skill.user === null; + + if (!isOwner && !isGlobal) { + return this.error( + "PERMISSION_DENIED", + "You can only update skills you own or global skills.", + ); + } + + const update: Record = {}; + + if (name) update.name = name; + if (description) update.description = description; + if (content) update.content = content; + + if (tagsStr) { + update.tags = tagsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + } + + if (modesArg && Array.isArray(modesArg)) { + update.modes = modesArg + .map((m) => m.toLowerCase()) + .filter((m) => ["build", "plan", "test", "ship", "dev"].includes(m)) + .map((m) => { + switch (m) { + case "build": + return ChatSessionMode.Build; + case "plan": + return ChatSessionMode.Plan; + case "test": + return ChatSessionMode.Test; + case "ship": + return ChatSessionMode.Ship; + case "dev": + return ChatSessionMode.Develop; + default: + return ChatSessionMode.Build; + } + }); + } + + await AiSkill.findByIdAndUpdate(skillId, update); + + const updated = await AiSkill.findById(skillId).lean(); + return this.success( + { skillId, name: updated?.name }, + `Skill updated: ${updated?.name}`, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return this.error( + "OPERATION_FAILED", + `Failed to update skill: ${errorMessage}`, + ); + } + } +} + +export default new UpdateSkillTool();