integrate docs/archive/* for reference until no longer needed

This commit is contained in:
Rob Colbert 2026-05-11 11:30:02 -04:00
parent 40cab7ca49
commit f2566f9b86
60 changed files with 10692 additions and 1 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
gadget*log gadget*log
logfetch logfetch
docs/archive .gadget
node_modules node_modules

View File

@ -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<IAiModelCapabilities>(
{
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<IAiModelSettings>(
{
temperature: { type: Number },
topP: { type: Number },
topK: { type: Number },
numCtx: { type: Number },
},
{ _id: false },
);
export const AiModelSchema = new Schema<IAiModel>(
{
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<IAiProvider>({
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<IAiProvider>("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.

View File

@ -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<IAiSkillHistory>({
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<IAiSkill>({
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<IAiSkill>("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.

View File

@ -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<IChatToolCallParameter>(
{
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<IChatFileOperation>(
{
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<IChatToolCall>({
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<IChatHistory>({
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<IChatHistory>(
"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.

View File

@ -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<IChatSessionPin>({
content: { type: String, required: true },
});
export const ChatSessionSchema = new Schema<IChatSession>({
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<IChatSession>(
"ChatSession",
ChatSessionSchema,
);
export default ChatSession;

View File

@ -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<ICsrfToken>({
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<ICsrfToken>("CsrfToken", CsrfTokenSchema);
export default CsrfToken;

View File

@ -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<IDroneWorkOrder>({
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<IDroneWorkOrder>(
"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.

View File

@ -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<IDrone>({
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<IDrone>("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.

View File

@ -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<IMemoryMonitor>({
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<IHostMonitor>({
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<IHostMonitor>(
"HostMonitor",
HostMonitorSchema,
);
export default HostMonitor;

View File

@ -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<IProject>({
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<IProject>("Project", ProjectSchema);
export default Project;

View File

@ -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<ISession>({
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<ISession>("Session", SessionSchema);
export default Session;

View File

@ -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<ISocketMessage>(
{
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<ISocketMessage>(
"SocketMessage",
SocketMessageSchema,
);
export default SocketMessage;

165
docs/archive/models/user.ts Normal file
View File

@ -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<IUserFlags>(
{
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<IAiConfig>(
{
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<IGabConnection>(
{
social: {
apiToken: { type: String, select: false },
},
ai: {
apiToken: { type: String, select: false },
},
},
{ _id: false },
);
const GoogleCseConnectionSchema = new Schema<IGoogleCseConnection>(
{
apiKey: { type: String, select: false },
engineId: { type: String, select: false },
},
{ _id: false },
);
const UserConnectionsSchema = new Schema<IUserServiceConnections>(
{
gab: { type: GabConnectionSchema, default: {} },
ai: { type: AiConfigSchema, default: {} },
google: {
cse: { type: GoogleCseConnectionSchema, default: null },
},
},
{ _id: false },
);
export const UserSchema = new Schema<IUser>({
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<IUser>("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.

View File

@ -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);
});
});
});

File diff suppressed because it is too large Load Diff

307
docs/archive/services/ai.ts Normal file
View File

@ -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<void> {
this.log.info("service started");
}
async stop(): Promise<void> {
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<AiClient> {
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<AiClient> {
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<AiClient> {
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<string> {
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<string> {
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<string> {
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<IAiModelSettings | null> {
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<ChatOptions> {
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<boolean> {
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<string> {
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();

View File

@ -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<void> {
this.log.info("service started", {
jwtExpiresIn: this.jwtExpiresIn,
refreshThreshold: this.jwtRefreshThreshold,
});
}
async stop(): Promise<void> {
this.log.info("service stopped");
}
/**
* Authenticate user with username and password
*/
async authenticate(
username: string,
password: string,
ipAddress?: string,
userAgent?: string,
): Promise<AuthResult> {
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<AuthTokens | null> {
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<boolean> {
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();

View File

@ -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<void> {
this.log.info("service started");
}
async stop(): Promise<void> {
this.log.info("service stopped");
}
async listByUser(userId: string): Promise<IChatSessionListItem[]> {
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<IChatSessionListItem[]> {
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<IChatSessionListItem> {
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<IChatSessionListItem | null> {
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<void> {
await ChatSession.findByIdAndUpdate(sessionId, { name });
this.log.info("ChatSession name updated", { sessionId, name });
}
async delete(sessionId: string, userId: string): Promise<void> {
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();

View File

@ -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<void> {
this.log.info("service started");
}
async stop(): Promise<void> {
this.log.info("service stopped");
}
// ==================== Session Management ====================
async listByProject(
projectId: string,
userId: string,
): Promise<IChatSessionListItem[]> {
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<IChatSessionListItem[]> {
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<IChatSessionListItem> {
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<IChatSessionDetail | null> {
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<void> {
await ChatSession.findByIdAndUpdate(sessionId, { name });
this.log.info("ChatSession name updated", { sessionId, name });
}
async updateMode(sessionId: string, mode: ChatSessionMode): Promise<void> {
await ChatSession.findByIdAndUpdate(sessionId, { mode });
this.log.info("ChatSession mode updated", { sessionId, mode });
}
async delete(sessionId: string, userId: string): Promise<void> {
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<IChatHistoryEntry[]> {
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<IChatHistoryEntry> {
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<void> {
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();

View File

@ -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<void> {
this.log.info("service started");
}
async stop(): Promise<void> {
this.log.info("service stopped");
}
middleware(options: CsrfTokenOptions): RequestHandler {
return async (
req: Request,
_res: Response,
next: NextFunction,
): Promise<void> => {
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<ICsrfToken> {
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();

View File

@ -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<void> {
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<void> {
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<void> {
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();

View File

@ -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<void> {
this.log.info("service started");
}
async stop(): Promise<void> {
this.log.info("service stopped");
}
async listByUser(userId: string): Promise<IProjectListItem[]> {
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<IProjectListItem | null> {
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<IProjectListItem> {
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<IProjectListItem | null> {
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<boolean> {
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();

View File

@ -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<void> {
this.log.info("service started");
}
async stop(): Promise<void> {
this.log.info("service stopped");
}
/**
* Check if a user has Google CSE credentials configured
*/
async userHasCseConfigured(userId: string): Promise<boolean> {
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<IGoogleCseConnection | null> {
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<SearchResult[]> {
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<SearchResult[]> {
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<SearchResult[]> {
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<SearchResult[]> {
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();

View File

@ -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<void> {
this.log.info("service started");
// Start session cleanup cron job
this.startCleanupJob();
}
async stop(): Promise<void> {
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<ISession> {
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<ISession | null> {
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<ISession | null> {
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<boolean> {
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<number> {
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<ISession> {
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();

View File

@ -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<void> {
this.log.info("service started");
}
async stop(): Promise<void> {
this.log.info("service stopped");
}
/**
* Create a new user account
*/
async create(
username: string,
password: string,
displayName: string,
isAdmin: boolean = false,
): Promise<IUser> {
// 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<IUser | null> {
const user = await User.findById(_id);
return user;
}
/**
* Get user by username (case-insensitive)
*/
async getUserByUsername(username: string): Promise<IUser | null> {
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<IUser | null> {
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<IUser | null> {
const user = await User.findOne({
username_lc: username.toLowerCase(),
}).select("+password +passwordSalt");
return user;
}
/**
* Verify user password
*/
async verifyPassword(user: IUser, password: string): Promise<boolean> {
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();

View File

@ -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<void> {
this.log.info("Initializing Qdrant client");
this.qdrant = new QdrantClient({
url: env.qdrant.host,
});
this.log.info("service started");
}
async stop(): Promise<void> {
this.log.info("service stopped");
}
async ensureCollection(name: string): Promise<void> {
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<number[]> {
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<string, unknown>,
) {
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<string> {
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();

View File

@ -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<void> {
this.log.info("service started");
await this.ensureCacheDir();
}
async stop(): Promise<void> {
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<void> {
await fs.mkdir(this.cacheDir, { recursive: true });
}
/**
* Reads content from cache if available
*/
private async readFromCache(url: string): Promise<FetchResult | null> {
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<void> {
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<FetchResult> {
// 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 <main> or <body>
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<FetchResult> {
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();

View File

@ -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<string> {
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<IChatHistoryExport[]> {
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();

View File

@ -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<string> {
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<string, unknown> = { 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();

View File

@ -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";

View File

@ -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<string> {
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();

View File

@ -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<string> {
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();

View File

@ -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);
});
});
});

View File

@ -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<string> {
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();

View File

@ -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<string> {
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();

View File

@ -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";

View File

@ -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("---");
});
});
});

View File

@ -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<string> {
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();

View File

@ -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+/);
});
});

View File

@ -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<string> {
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();

View File

@ -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":');
});
});

View File

@ -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<string> {
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();

View File

@ -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<string, DtpTool>();
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);
});
}

View File

@ -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";

View File

@ -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<string> {
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();

View File

@ -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<string> {
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();

View File

@ -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");
});
});
});

View File

@ -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<string> {
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<string[]> {
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<void> {
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();

View File

@ -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<boolean> {
return await SearchService.userHasCseConfigured(userId);
}
public async execute(
context: ToolContext,
args: ToolArguments,
): Promise<string> {
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();

View File

@ -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");
});
});
});

View File

@ -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<string> {
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<Array<{ file: string; line: number; content: string }>> {
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<void> {
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();

View File

@ -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);

View File

@ -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");
});
});
});

View File

@ -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<string> {
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();

View File

@ -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<void> {
// 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);
}

View File

@ -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<string> {
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();

View File

@ -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<string> {
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();

View File

@ -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 };

View File

@ -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<string> {
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();

View File

@ -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<string> {
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<string, unknown> = {};
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();