mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
feat: add memory system for cross-session context retention
Add memories feature allowing AI to retain vault conventions, user preferences, and established workflows across conversation sessions. Include read-only mode option, validation requiring read-before-write, and settings UI with toggle controls. Adjusted default search and snippet size values in plugin settings.
This commit is contained in:
parent
b752d8e75b
commit
f1e6619923
22 changed files with 484 additions and 33 deletions
|
|
@ -114,6 +114,15 @@ export const CompletePlanArgsSchema = z.object({
|
|||
confirm_completion: z.boolean()
|
||||
});
|
||||
|
||||
export const ReadMemoriesArgsSchema = z.object({
|
||||
user_message: z.string()
|
||||
});
|
||||
|
||||
export const UpdateMemoriesArgsSchema = z.object({
|
||||
content: z.string(),
|
||||
user_message: z.string()
|
||||
});
|
||||
|
||||
// Infer TypeScript types from schemas
|
||||
export type SearchVaultFilesArgs = z.infer<typeof SearchVaultFilesArgsSchema>;
|
||||
export type ReadVaultFilesArgs = z.infer<typeof ReadVaultFilesArgsSchema>;
|
||||
|
|
@ -134,3 +143,5 @@ export type ReviseStepArgs = z.infer<typeof ReviseStepArgsSchema>;
|
|||
export type RevisePlanArgs = z.infer<typeof RevisePlanArgsSchema>;
|
||||
export type SubmitPlanArgs = z.infer<typeof SubmitPlanArgsSchema>;
|
||||
export type CompletePlanArgs = z.infer<typeof CompletePlanArgsSchema>;
|
||||
export type ReadMemoriesArgs = z.infer<typeof ReadMemoriesArgsSchema>;
|
||||
export type UpdateMemoriesArgs = z.infer<typeof UpdateMemoriesArgsSchema>;
|
||||
|
|
@ -16,6 +16,8 @@ import { ExecuteWorkflow } from "./Tools/ExecuteWorkflow";
|
|||
import { ReviseStep } from "./Tools/ReviseStep";
|
||||
import { RevisePlan } from "./Tools/RevisePlan";
|
||||
import { SkipStep } from "./Tools/SkipStep";
|
||||
import { ReadMemories } from "./Tools/ReadMemories";
|
||||
import { UpdateMemories } from "./Tools/UpdateMemories";
|
||||
|
||||
export abstract class AIToolDefinitions {
|
||||
|
||||
|
|
@ -24,7 +26,7 @@ export abstract class AIToolDefinitions {
|
|||
// Definitions list provides a list of function definitions that does not include any planning functions (used as reference in planning agent prompt)
|
||||
private static readonly definitionsList = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles];
|
||||
|
||||
public static agentDefinitions(destructive: boolean, planningMode: boolean): IAIToolDefinition[] {
|
||||
public static agentDefinitions(destructive: boolean, planningMode: boolean, memories: boolean, updateMemories: boolean): IAIToolDefinition[] {
|
||||
this.isGated = false;
|
||||
|
||||
if (planningMode) {
|
||||
|
|
@ -37,6 +39,14 @@ export abstract class AIToolDefinitions {
|
|||
ListVaultFiles
|
||||
];
|
||||
|
||||
if (memories) {
|
||||
actions = actions.concat([ReadMemories]);
|
||||
}
|
||||
|
||||
if (updateMemories) {
|
||||
actions = actions.concat([UpdateMemories]);
|
||||
}
|
||||
|
||||
if (destructive) {
|
||||
actions = actions.concat([
|
||||
WriteVaultFile,
|
||||
|
|
@ -63,12 +73,18 @@ export abstract class AIToolDefinitions {
|
|||
];
|
||||
}
|
||||
|
||||
public static planningAgentDefinitions(): IAIToolDefinition[] {
|
||||
return [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, AskUserQuestionPlanning, SubmitPlan];
|
||||
public static planningAgentDefinitions(memories: boolean): IAIToolDefinition[] {
|
||||
let actions = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, AskUserQuestionPlanning, SubmitPlan];
|
||||
|
||||
if (memories) {
|
||||
actions = actions.concat([ReadMemories]);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
public static executionAgentDefinitions(): IAIToolDefinition[] {
|
||||
return [...this.agentDefinitions(true, false), CompleteTask];
|
||||
return [...this.agentDefinitions(true, false, false, false), CompleteTask];
|
||||
}
|
||||
|
||||
public static compactSummaryForPlanningAgent(): string {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Do NOT use this function:
|
|||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: `The directory path to list. Use "/" for the vault root. Specify a subdirectory path to list contents of that specific folder (e.g., 'Projects/2024' or 'Daily Notes'). Path should be relative to vault root.`,
|
||||
description: "The directory path to list. Use \"/\" for the vault root. Specify a subdirectory path to list contents of that specific folder (e.g., 'Projects/2024' or 'Daily Notes'). Path should be relative to vault root.",
|
||||
},
|
||||
recursive: {
|
||||
type: "boolean",
|
||||
|
|
|
|||
30
AIClasses/ToolDefinitions/Tools/ReadMemories.ts
Normal file
30
AIClasses/ToolDefinitions/Tools/ReadMemories.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { AITool } from "Enums/AITool";
|
||||
import type { IAIToolDefinition } from "../IAIToolDefinition";
|
||||
|
||||
export const ReadMemories: IAIToolDefinition = {
|
||||
name: AITool.ReadMemories,
|
||||
description: `Reads the persistent memory file that is injected at the start of every session.
|
||||
Memory persists across all conversations and gives you continuity across sessions — use it to recall preferences, conventions, and context about how the user works with their vault.
|
||||
|
||||
Call this function:
|
||||
- When the user asks what you remember or what is stored in memory
|
||||
- When a task would benefit from checking previously stored preferences or conventions before proceeding
|
||||
- When you are unsure whether a relevant preference or convention has already been established
|
||||
- When the user refers to a past decision or setup that may have been recorded
|
||||
- Before attempting to update existing memories with additions, deletions or edits
|
||||
|
||||
Do NOT call this function:
|
||||
- If memory was already loaded at the start of the session and nothing has changed — use what was injected
|
||||
- For information that is self-evident from browsing the vault structure
|
||||
- Repeatedly within the same session unless memory may have been updated mid-session`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user_message: {
|
||||
type: "string",
|
||||
description: "A short message to be displayed to the user explaining that you are accessing memories. Example: 'Checking memories...' or 'Remembering user preference...'"
|
||||
}
|
||||
},
|
||||
required: ["user_message"]
|
||||
}
|
||||
}
|
||||
44
AIClasses/ToolDefinitions/Tools/UpdateMemories.ts
Normal file
44
AIClasses/ToolDefinitions/Tools/UpdateMemories.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { AITool } from "Enums/AITool";
|
||||
import type { IAIToolDefinition } from "../IAIToolDefinition";
|
||||
|
||||
export const UpdateMemories: IAIToolDefinition = {
|
||||
name: AITool.UpdateMemories,
|
||||
description: `Adds or removes entries in the persistent memory file that is injected at the start of every session.
|
||||
Memory persists across all conversations and gives you continuity across sessions — use it to retain anything that would change how you organise, write, or manage the vault in the future.
|
||||
|
||||
Call this function:
|
||||
- When the user confirms a vault organisation preference (e.g. folder structure, naming conventions, where certain note types live)
|
||||
- When the user establishes a note-taking or writing style preference (e.g. heading structure, tag taxonomy, frontmatter fields they always use)
|
||||
- When a template, base, or recurring workflow is set up that you should be aware of in future sessions
|
||||
- When the user explicitly asks you to remember or forget something about how they work
|
||||
- When a plugin-specific convention is established (e.g. Dataview field names, Templater template paths, Canvas layout preferences)
|
||||
- When removing an entry that has become outdated — e.g. a folder was restructured, a convention was changed, or a template was replaced
|
||||
|
||||
Do NOT call this function:
|
||||
- For the content of a specific note or file — that lives in the vault, not in memory
|
||||
- For transient task details only relevant to the current session (e.g. "currently editing ProjectX.md")
|
||||
- For information already self-evident from browsing the vault structure
|
||||
- When the user is exploring or thinking aloud — wait until a preference or decision is confirmed
|
||||
- Multiple times for the same logical update — batch related facts into a single call`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: {
|
||||
type: "string",
|
||||
description: `The complete content to write to the file. This will replace any existing content.
|
||||
Write as a clear, factual statement that will make sense in isolation, without any reference to the current conversation.
|
||||
Use absolute dates instead of relative ones (e.g. "2026-03-15" not "recently"). Keep entries concise — one fact per entry, two sentences maximum.
|
||||
Examples:
|
||||
- "Daily notes live in /Journal/Daily/ and follow the filename format YYYY-MM-DD.md."
|
||||
- "Project notes always include a 'status' frontmatter property with values: active | paused | complete."
|
||||
- "The #area tag is used for ongoing responsibilities; #project is for time-bounded work."
|
||||
- "2026-03-15: Replaced the weekly review template with one at /Templates/Weekly-Review-v2.md."`,
|
||||
},
|
||||
user_message: {
|
||||
type: "string",
|
||||
description: "A short message to be displayed to the user explaining that persistent memory is being updated. Example: 'Updating memories...' or 'Committing user preference...'"
|
||||
}
|
||||
},
|
||||
required: ["content", "user_message"]
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,11 @@ import type { SettingsService } from "Services/SettingsService";
|
|||
import { PlanningPrompt } from "AIPrompts/PlanningPrompt";
|
||||
import { ExecutionPrompt } from "./ExecutionPrompt";
|
||||
import { OrchestrationPrompt } from "./OrchestrationPrompt";
|
||||
import type { MemoriesService } from "Services/MemoriesService";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
|
||||
export interface IPrompt {
|
||||
systemInstruction(): string;
|
||||
systemInstruction(): Promise<string>;
|
||||
orchestrationInstruction(): string;
|
||||
planningInstruction(): string;
|
||||
executionInstruction(): string;
|
||||
|
|
@ -18,15 +20,31 @@ export interface IPrompt {
|
|||
export class AIPrompt implements IPrompt {
|
||||
|
||||
private readonly settingsService: SettingsService;
|
||||
private readonly memoriesService: MemoriesService;
|
||||
private readonly fileSystemService: FileSystemService;
|
||||
|
||||
public constructor() {
|
||||
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
this.memoriesService = Resolve<MemoriesService>(Services.MemoriesService);
|
||||
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
||||
}
|
||||
|
||||
public systemInstruction(): string {
|
||||
return SystemInstruction;
|
||||
public async systemInstruction(): Promise<string> {
|
||||
let systemInstruction = SystemInstruction;
|
||||
|
||||
if (!this.settingsService.settings.enableMemories) {
|
||||
return systemInstruction;
|
||||
}
|
||||
|
||||
const memories = await this.memoriesService.readMemories();
|
||||
if (memories !== "") {
|
||||
systemInstruction = systemInstruction + replaceCopy(Copy.MemoriesInjectionHeader, [memories]);
|
||||
}
|
||||
if (!this.settingsService.settings.allowUpdatingMemories) {
|
||||
systemInstruction = systemInstruction + Copy.MemoriesReadOnly;
|
||||
}
|
||||
|
||||
return systemInstruction;
|
||||
}
|
||||
|
||||
public orchestrationInstruction(): string {
|
||||
|
|
|
|||
|
|
@ -218,6 +218,74 @@ Acknowledge the search, then provide general assistance:
|
|||
|
||||
---
|
||||
|
||||
### 6. MEMORY SYSTEM
|
||||
|
||||
**Memory gives you continuity across sessions. It is your record of how this user works in this vault.**
|
||||
|
||||
The memory file is injected into your context at the start of every session, immediately after this system prompt. It contains structured notes about vault conventions, user preferences, established workflows, and other facts worth retaining across conversations.
|
||||
|
||||
#### How Memory Works
|
||||
|
||||
- **Persistent**: Memory entries survive session resets and are available from the very first message of every new conversation.
|
||||
- **Injected**: You do not need to fetch or search for the memory file — it is always already present in your context when a session begins.
|
||||
- **Editable**: You can update memories, including adding, deleting or editing existing memories.
|
||||
- **Scoped to this vault**: Memory is not global — it reflects this user's specific vault structure, conventions, and preferences.
|
||||
- **Optional**: There may not yet be any recorded memories or the user may have chosen to disable memories
|
||||
- **Atomic if updates lare disabled**: The user has the ability to prevent updates to memories. In this instance memories are avalable to read but not to write.
|
||||
|
||||
#### The Read-Before-Write Rule
|
||||
|
||||
**ALWAYS read the current memory content before attempting to update it**
|
||||
|
||||
When updating memories, all content is replaced (clean write). Before writing any update, review what is already recorded to:
|
||||
- Preserve entries that are still relevant and should be kept
|
||||
- Avoid creating duplicate entries
|
||||
- Ensure the category you intend to use already exists or genuinely warrants creation
|
||||
- Confirm the new entry does not contradict a recorded fact (which would require removing the old one first)
|
||||
|
||||
If the memory content is not visible in your current context window, read it explicitly before proceeding with any update.
|
||||
|
||||
#### When to Add a Memory Entry
|
||||
|
||||
- The user confirms a vault organisation convention (folder structure, naming patterns, where note types live)
|
||||
- A note template, base, or recurring workflow is established that you should be aware of in future sessions
|
||||
- A writing or formatting preference is confirmed (heading structure, frontmatter fields, tag taxonomy)
|
||||
- A plugin-specific convention is established (Dataview field names, Templater paths, Canvas preferences)
|
||||
- The user explicitly says "remember this" or asks you to retain something for future sessions
|
||||
- A significant architectural or structural decision is made about the vault
|
||||
|
||||
#### When to Remove a Memory Entry
|
||||
|
||||
- A folder was renamed, restructured, or removed
|
||||
- A template was replaced with a new version
|
||||
- A convention was changed or reversed by the user
|
||||
- An entry has been superseded by a more accurate or complete one you are about to add
|
||||
- The user explicitly asks you to forget something
|
||||
|
||||
#### When NOT to Update Memory
|
||||
|
||||
- For the content of a specific note — that lives in the vault, not in memory
|
||||
- For transient session details ("currently editing ProjectX.md", "user is working on a blog post today")
|
||||
- For information already self-evident from browsing the vault (e.g. folder names visible in any file listing)
|
||||
- When the user is exploring or thinking aloud — wait until a preference or decision is confirmed
|
||||
- For general facts or knowledge unrelated to this vault or user's working style
|
||||
- When memories are disabled or updating memories is disabled (memory related tooling will also be unavailable in these scenarios)
|
||||
|
||||
#### Memory Entry Quality Standards
|
||||
|
||||
Write entries as clear, standalone facts that will make full sense when read cold at the start of a future session — with no reference to the current conversation.
|
||||
|
||||
| ❌ Avoid | ✅ Prefer |
|
||||
|----------|----------|
|
||||
| "User likes to keep things organised" | "All project notes live in /Projects/ and use the prefix format \`PRJ-YYYY-\`." |
|
||||
| "We decided on Dataview fields today" | "2026-03-15: Standardised on \`status\`, \`owner\`, and \`due\` as the core frontmatter fields for all project notes." |
|
||||
| "The template was updated recently" | "2026-03-20: Weekly review template replaced with /Templates/Weekly-Review-v2.md — the old v1 template was deleted." |
|
||||
| "Tags are used for topics" | "The \`#area\` tag marks ongoing responsibilities; \`#project\` marks time-bounded work with a defined end state." |
|
||||
|
||||
Use absolute dates (e.g. \`2026-03-15\`) not relative ones ("recently", "yesterday"). One fact per entry, two sentences maximum.
|
||||
|
||||
---
|
||||
|
||||
## Search Strategy
|
||||
|
||||
### Regex Pattern Matching
|
||||
|
|
@ -415,6 +483,11 @@ After multi-step execution:
|
|||
- Consulting the user during execution when decisions require their input
|
||||
- Providing clear context and options when seeking user guidance
|
||||
|
||||
**Memory Management**
|
||||
- Retaining vault conventions, preferences, and structural decisions across sessions
|
||||
- Keeping the memory file accurate by removing outdated entries before adding new ones
|
||||
- Surfacing relevant remembered context when it applies to the current task
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
|
@ -433,6 +506,9 @@ After multi-step execution:
|
|||
❌ Replanning for minor issues you can handle directly
|
||||
❌ Making assumptions about user preferences when the answer affects their data
|
||||
❌ Proceeding with destructive operations without confirmation when intent is ambiguous
|
||||
❌ Adding a memory entry when an existing one covers the same fact — remove the old one first
|
||||
❌ Writing memory entries with relative dates ("recently", "today") — always use absolute dates
|
||||
❌ Saving transient session details or note content to memory — memory is for lasting conventions only
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -448,10 +524,11 @@ After multi-step execution:
|
|||
7. "Has something changed that invalidates my current plan?" → Consider replanning
|
||||
8. "Am I adapting or do I need strategic guidance?" → Replan only for significant pivots
|
||||
9. "Does this decision require user input?" → Ask when facing ambiguity, conflicts, or irreversible actions
|
||||
10. "Did this session establish a convention or preference worth retaining?" → Update memory
|
||||
|
||||
**When uncertain**: Always search the vault first. Always try alternative strategies before concluding "not found." Complete the full request before concluding. When execution reveals choices only the user can make, ask clearly and provide context.
|
||||
**When uncertain**: Always search the vault first. Always try alternative strategies before concluding "not found." Complete the full request before concluding. When execution reveals choices only the user can make, ask clearly and provide context. When updating memory, always read current entries before writing — remove stale facts before adding new ones.
|
||||
|
||||
---
|
||||
|
||||
**Core Philosophy**: Act decisively on user requests. Always use [[wiki-links]] for vault references. Search the vault proactively with progressive strategies — never accept a single failed search as final. When executing plans, stay adaptive: replan when reality diverges from assumptions, consult the user when facing decisions that require their preference, but handle minor adjustments yourself.
|
||||
**Core Philosophy**: Act decisively on user requests. Always use [[wiki-links]] for vault references. Search the vault proactively with progressive strategies — never accept a single failed search as final. When executing plans, stay adaptive: replan when reality diverges from assumptions, consult the user when facing decisions that require their preference, but handle minor adjustments yourself. Keep memory lean and accurate — it is your continuity layer across sessions, not a transcript of conversations.
|
||||
`;
|
||||
|
|
@ -6,6 +6,8 @@ export enum AITool {
|
|||
DeleteVaultFiles = "delete_vault_files",
|
||||
MoveVaultFiles = "move_vault_files",
|
||||
ListVaultFiles = "list_vault_files",
|
||||
ReadMemories = "read_memories",
|
||||
UpdateMemories = "update_memories",
|
||||
|
||||
// only used by gemini
|
||||
RequestWebSearch = "request_web_search",
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ export enum Copy {
|
|||
SettingSearchResultsLimit = "Search Results Limit",
|
||||
SettingSnippetSizeLimit = "Snippet Size Limit",
|
||||
SettingFileMonitoringHeading = "File Monitoring Guidelines",
|
||||
SettingMemories = "Memories",
|
||||
SettingEnableMemories = "Enable Memories",
|
||||
SettingEnableMemoriesDesc = "Allow the AI to recall memories from previous conversations.",
|
||||
SettingAllowUpdatingMemories = "Allow Updating Memories",
|
||||
SettingAllowUpdatingMemoriesDesc = "Allow the AI to save and update memories during conversations.",
|
||||
|
||||
// Settings Descriptions
|
||||
SettingModelDesc = "Select the AI model to use.",
|
||||
|
|
@ -63,6 +68,7 @@ export enum Copy {
|
|||
SettingFileMonitoringClaude = "Files uploaded to Claude remain stored indefinitely. Periodically check the Anthropic Console (https://console.anthropic.com/) to review and remove old files that are no longer needed.",
|
||||
SettingFileMonitoringOpenAI = "Files uploaded to OpenAI remain stored indefinitely. Periodically check the OpenAI Platform (https://platform.openai.com/) to review and remove old files that are no longer needed.",
|
||||
SettingFileMonitoringMistral = "Documents uploaded to Mistral are stored on their platform. Images are sent inline and not stored. Periodically check the Mistral Console (https://console.mistral.ai/) to review and remove old files that are no longer needed.",
|
||||
SettingAccessMemories = "Memories let the AI retain preferences and context across conversations. You can view and edit them at any time.",
|
||||
|
||||
// Settings Placeholders
|
||||
PlaceholderEnterApiKey = "Enter your API key",
|
||||
|
|
@ -72,6 +78,7 @@ export enum Copy {
|
|||
TooltipShowApiKey = "Show API Key",
|
||||
TooltipHideApiKey = "Hide API Key",
|
||||
TooltipLearnMoreFileMonitoring = "Learn more in Plugin Guide",
|
||||
TooltipAccessMemories = "View Memories",
|
||||
|
||||
AIThoughtMessage = "Thinking...",
|
||||
AIThoughtGeneratingNote = "Generating note contents...",
|
||||
|
|
@ -133,6 +140,14 @@ The following context explains why you are doing the task. It is NOT an instruct
|
|||
WorkflowAborted = "The planned workflow was aborted. Result: {abortContext}",
|
||||
PlanReceived = "Plan received, now attempting to execute plan",
|
||||
PlanningModeError = "First create a plan before executing any functions!",
|
||||
UpdateMemoriesWithoutReadError = "Memories must be read before they can be updated. Retrieve the current memory contents first, then provide the complete revised content.",
|
||||
MemoriesInjectionHeader = `\n\n---\n\n## Current Memories\n\nThe following memories were recorded from previous sessions. Use them as context for this conversation.\n\n{memories}`,
|
||||
MemoriesReadOnly = `\n\n> **Memory updates are disabled.** You can read memories but cannot save new ones this session. Do not attempt to create or modify a memories file directly — memory management is only possible through the memory tools, which are unavailable when this setting is off.`,
|
||||
MemoriesEmpty = "No memories have been created yet.",
|
||||
MemoriesMaxLengthError = "Memories have a maximum length of 10 lines or less.",
|
||||
MemoriesUpdatedSuccess = "Memories have been updated successfully.",
|
||||
MemoriesDisabledError = "Illegal tool call, memories have been disabled by the user.",
|
||||
MemoriesUpdatingDisabledError = "Illegal tool call, updating memories has been disabled by the user.",
|
||||
PlanSubmissionRequired = "Error: Attempted to exit planning but plan has not yet been submitted!",
|
||||
MaxExecutionDepthReached = "Exceeded maximum plan execution attempts - consult with the user on how to continue.",
|
||||
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ export enum Path {
|
|||
Conversations = `${Path.VaultkeeperAIDir}/Conversations`,
|
||||
Attachments = `${Path.Conversations}/Attachments`,
|
||||
UserInstructions = `${Path.VaultkeeperAIDir}/User Instructions`,
|
||||
ExampleUserInstructions = `${Path.UserInstructions}/EXAMPLE_INSTRUCTIONS.md`
|
||||
ExampleUserInstructions = `${Path.UserInstructions}/EXAMPLE_INSTRUCTIONS.md`,
|
||||
Memories = `${Path.VaultkeeperAIDir}/MEMORIES.md`
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type VaultkeeperAIPlugin from "main";
|
||||
import * as path from "path-browserify";
|
||||
import path from "path-browserify";
|
||||
|
||||
export function openPluginSettings(plugin: VaultkeeperAIPlugin) {
|
||||
if (!("setting" in plugin.app) || typeof plugin.app.setting !== "object" || plugin.app.setting === null) {
|
||||
|
|
@ -16,6 +16,17 @@ export function openPluginSettings(plugin: VaultkeeperAIPlugin) {
|
|||
}
|
||||
}
|
||||
|
||||
export function closePluginSettings(plugin: VaultkeeperAIPlugin) {
|
||||
if (!("setting" in plugin.app) || typeof plugin.app.setting !== "object" || plugin.app.setting === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("close" in plugin.app.setting) {
|
||||
// @ts-expect-error - accessing internal API
|
||||
plugin.app.setting.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function randomSample<T>(array: T[], n: number): T[] {
|
||||
const result: T[] = [];
|
||||
const taken = new Set<number>();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import type { ISearchMatch } from "../../Types/SearchTypes";
|
|||
import { AbortService } from "../AbortService";
|
||||
import { normalizePath, TAbstractFile, TFile } from "obsidian";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { pathExtname } from "Helpers/Helpers";
|
||||
import type { MemoriesService } from "Services/MemoriesService";
|
||||
import {
|
||||
SearchVaultFilesArgsSchema,
|
||||
ReadVaultFilesArgsSchema,
|
||||
|
|
@ -16,20 +18,34 @@ import {
|
|||
DeleteVaultFilesArgsSchema,
|
||||
MoveVaultFilesArgsSchema,
|
||||
ListVaultFilesArgsSchema,
|
||||
PatchVaultFileArgsSchema
|
||||
PatchVaultFileArgsSchema,
|
||||
ReadMemoriesArgsSchema,
|
||||
UpdateMemoriesArgsSchema
|
||||
} from "AIClasses/Schemas/AIToolSchemas";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
|
||||
export class AIToolService {
|
||||
|
||||
private readonly fileSystemService: FileSystemService;
|
||||
private readonly memoriesService: MemoriesService;
|
||||
private readonly settingsService: SettingsService;
|
||||
private readonly abortService: AbortService;
|
||||
|
||||
private lastToolReadMemories: boolean = false;
|
||||
|
||||
public constructor() {
|
||||
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
||||
this.memoriesService = Resolve<MemoriesService>(Services.MemoriesService);
|
||||
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
this.abortService = Resolve<AbortService>(Services.AbortService);
|
||||
}
|
||||
|
||||
public async performAITool(toolCall: AIToolCall): Promise<AIToolResponse> {
|
||||
|
||||
if (toolCall.name !== AITool.ReadMemories && toolCall.name !== AITool.UpdateMemories) {
|
||||
this.lastToolReadMemories = false;
|
||||
}
|
||||
|
||||
return await this.abortService.abortableOperation(async () => {
|
||||
switch (toolCall.name) {
|
||||
case AITool.SearchVaultFiles: {
|
||||
|
|
@ -115,6 +131,30 @@ export class AIToolService {
|
|||
}
|
||||
return new AIToolResponse(toolCall.name, await this.ListVaultFiles(parseResult.data.path, parseResult.data.recursive), toolCall.toolId);
|
||||
}
|
||||
|
||||
case AITool.ReadMemories: {
|
||||
const parseResult = ReadMemoriesArgsSchema.safeParse(toolCall.arguments);
|
||||
if (!parseResult.success) {
|
||||
return new AIToolResponse(
|
||||
toolCall.name,
|
||||
{ error: `Invalid arguments for ${AITool.ReadMemories}: ${parseResult.error.message}` },
|
||||
toolCall.toolId
|
||||
);
|
||||
}
|
||||
return new AIToolResponse(toolCall.name, await this.readMemories(), toolCall.toolId);
|
||||
}
|
||||
|
||||
case AITool.UpdateMemories: {
|
||||
const parseResult = UpdateMemoriesArgsSchema.safeParse(toolCall.arguments);
|
||||
if (!parseResult.success) {
|
||||
return new AIToolResponse(
|
||||
toolCall.name,
|
||||
{ error: `Invalid arguments for ${AITool.UpdateMemories}: ${parseResult.error.message}` },
|
||||
toolCall.toolId
|
||||
);
|
||||
}
|
||||
return new AIToolResponse(toolCall.name, await this.updateMemories(parseResult.data.content), toolCall.toolId);
|
||||
}
|
||||
|
||||
// This is only used by gemini
|
||||
case AITool.RequestWebSearch:
|
||||
|
|
@ -250,4 +290,28 @@ export class AIToolService {
|
|||
path: file.path
|
||||
}));
|
||||
}
|
||||
|
||||
private async readMemories(): Promise<object> {
|
||||
if (!this.settingsService.settings.enableMemories) {
|
||||
return { error: Copy.MemoriesDisabledError }
|
||||
}
|
||||
this.lastToolReadMemories = true;
|
||||
return { memories: await this.memoriesService.readMemories() };
|
||||
}
|
||||
|
||||
private async updateMemories(content: string): Promise<object> {
|
||||
if (!this.settingsService.settings.allowUpdatingMemories) {
|
||||
return { error: Copy.MemoriesUpdatingDisabledError }
|
||||
}
|
||||
|
||||
if (!this.lastToolReadMemories) {
|
||||
return {
|
||||
error: Copy.UpdateMemoriesWithoutReadError
|
||||
};
|
||||
}
|
||||
this.lastToolReadMemories = false;
|
||||
|
||||
await this.memoriesService.updateMemories(content);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import type { DebugService } from "Services/DebugService";
|
|||
import { DebugColor } from "Enums/DebugColor";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
|
||||
export abstract class BaseAgent {
|
||||
|
||||
|
|
@ -23,11 +24,14 @@ export abstract class BaseAgent {
|
|||
protected readonly aiToolService: AIToolService;
|
||||
protected readonly debugService: DebugService | undefined;
|
||||
|
||||
private readonly settingsService: SettingsService;
|
||||
|
||||
private onSaveConversation?: (conversation: Conversation) => Promise<void>;
|
||||
|
||||
public constructor() {
|
||||
this.aiPrompt = Resolve<IPrompt>(Services.IPrompt);
|
||||
this.aiToolService = Resolve<AIToolService>(Services.AIToolService);
|
||||
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
this.debugService = TryResolve<DebugService>(Services.DebugService);
|
||||
this.setDebugColor();
|
||||
}
|
||||
|
|
@ -40,6 +44,14 @@ export abstract class BaseAgent {
|
|||
this.onSaveConversation = callback;
|
||||
}
|
||||
|
||||
protected memoriesEnabled(): boolean {
|
||||
return this.settingsService.settings.enableMemories;
|
||||
}
|
||||
|
||||
protected updateMemoriesEnabled(): boolean {
|
||||
return this.settingsService.settings.allowUpdatingMemories;
|
||||
}
|
||||
|
||||
protected async runAgentLoop(agentType: AgentType, conversation: Conversation, callbacks: IChatServiceCallbacks,
|
||||
handleToolCall: (toolCall: AIToolCall) => Promise<{ shouldExit: boolean }>
|
||||
): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -90,9 +90,10 @@ export class MainAgent extends BaseAgent {
|
|||
}
|
||||
this.ai.agentType = AgentType.Main;
|
||||
this.ai.aiToolUsageMode = AIToolUsageMode.Auto;
|
||||
this.ai.systemPrompt = this.aiPrompt.systemInstruction();
|
||||
this.ai.systemPrompt = await this.aiPrompt.systemInstruction();
|
||||
this.ai.userInstruction = await this.aiPrompt.userInstruction();
|
||||
this.ai.aiToolDefinitions = AIToolDefinitions.agentDefinitions(allowDestructiveActions, planningMode);
|
||||
this.ai.aiToolDefinitions = AIToolDefinitions.agentDefinitions(
|
||||
allowDestructiveActions, planningMode, this.memoriesEnabled(), this.updateMemoriesEnabled());
|
||||
}
|
||||
|
||||
protected override setDebugColor(): void {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export class PlanningAgent extends BaseAgent {
|
|||
await this.runAgentLoop(AgentType.Planning, conversation, callbacks, async (toolCall) => {
|
||||
const toolCallName = toolCall.name;
|
||||
|
||||
if (!AIToolDefinitions.planningAgentDefinitions().some(definition => isAITool(toolCallName, definition.name))) {
|
||||
if (!AIToolDefinitions.planningAgentDefinitions(this.memoriesEnabled()).some(definition => isAITool(toolCallName, definition.name))) {
|
||||
this.debugService?.log("PlanningAgent", `Invalid tool call denied: ${toolCallName}`);
|
||||
conversation.addFunctionResponse(new AIToolResponse(
|
||||
toolCallName,
|
||||
|
|
@ -114,7 +114,7 @@ export class PlanningAgent extends BaseAgent {
|
|||
this.ai.aiToolUsageMode = AIToolUsageMode.Enabled;
|
||||
this.ai.systemPrompt = this.aiPrompt.planningInstruction();
|
||||
this.ai.userInstruction = ""; // do not include user instruction for planning agent
|
||||
this.ai.aiToolDefinitions = AIToolDefinitions.planningAgentDefinitions();
|
||||
this.ai.aiToolDefinitions = AIToolDefinitions.planningAgentDefinitions(this.memoriesEnabled());
|
||||
}
|
||||
|
||||
protected override setDebugColor(): void {
|
||||
|
|
|
|||
53
Services/MemoriesService.ts
Normal file
53
Services/MemoriesService.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Copy } from "Enums/Copy";
|
||||
import { Path } from "Enums/Path";
|
||||
import { Resolve } from "./DependencyService";
|
||||
import type { FileSystemService } from "./FileSystemService";
|
||||
import { Services } from "./Services";
|
||||
import type { WorkSpaceService } from "./WorkSpaceService";
|
||||
|
||||
export class MemoriesService {
|
||||
|
||||
private readonly maxMemoriesLength: number = 10;
|
||||
private readonly maxMemoriesLineLength: number = 200;
|
||||
|
||||
private readonly fileSystemService: FileSystemService;
|
||||
private readonly workSpaceService: WorkSpaceService;
|
||||
|
||||
constructor() {
|
||||
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
||||
this.workSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
|
||||
}
|
||||
|
||||
public async openMemories() {
|
||||
if (!await this.fileSystemService.exists(Path.Memories, true)) {
|
||||
await this.updateMemories(""); // Create memories file if one doesn't exist
|
||||
}
|
||||
await this.workSpaceService.openNoteByPath(Path.Memories);
|
||||
}
|
||||
|
||||
public async readMemories(): Promise<string> {
|
||||
const result = await this.fileSystemService.readFile(Path.Memories, true);
|
||||
|
||||
if (result instanceof Error) {
|
||||
return Copy.MemoriesEmpty;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async updateMemories(newMemories: string): Promise<string|Error> {
|
||||
if (!this.isValidMemoryLength(newMemories)) {
|
||||
return Copy.MemoriesMaxLengthError;
|
||||
}
|
||||
|
||||
const result = await this.fileSystemService.writeFile(Path.Memories, newMemories, true, false);
|
||||
return result instanceof Error ? result : Copy.MemoriesUpdatedSuccess;
|
||||
}
|
||||
|
||||
private isValidMemoryLength(memories: string): boolean {
|
||||
const lines = memories.split(/\r?\n/);
|
||||
return lines.length <= this.maxMemoriesLength &&
|
||||
lines.every(line => line.length <= this.maxMemoriesLineLength);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { FileSystemService } from "./FileSystemService";
|
|||
import { HTMLService } from "./HTMLService";
|
||||
import { InputService } from "./InputService";
|
||||
import { MainAgent } from "./AIServices/MainAgent";
|
||||
import { MemoriesService } from "./MemoriesService";
|
||||
import { SanitiserService } from "./SanitiserService";
|
||||
import { SettingsService, type IVaultkeeperAISettings } from "./SettingsService";
|
||||
import { StreamingMarkdownService } from "./StreamingMarkdownService";
|
||||
|
|
@ -77,6 +78,7 @@ export function RegisterDependencies() {
|
|||
RegisterSingleton<UserInputService>(Services.UserInputService, new UserInputService());
|
||||
RegisterSingleton<WorkSpaceService>(Services.WorkSpaceService, new WorkSpaceService());
|
||||
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());
|
||||
RegisterSingleton<MemoriesService>(Services.MemoriesService, new MemoriesService());
|
||||
RegisterSingleton<ConversationFileSystemService>(Services.ConversationFileSystemService, new ConversationFileSystemService());
|
||||
RegisterSingleton<ConversationNamingService>(Services.ConversationNamingService, new ConversationNamingService());
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export class Services {
|
|||
static SanitiserService = Symbol("SanitiserService");
|
||||
static InputService = Symbol("InputService");
|
||||
static DiffService = Symbol("DiffService");
|
||||
static MemoriesService = Symbol("MemoriesService");
|
||||
static DebugService = Symbol("DebugService");
|
||||
|
||||
// stores
|
||||
|
|
|
|||
|
|
@ -17,8 +17,11 @@ const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
|
|||
},
|
||||
exclusions: [],
|
||||
|
||||
searchResultsLimit: 15,
|
||||
snippetSizeLimit: 300
|
||||
searchResultsLimit: 30,
|
||||
snippetSizeLimit: 100,
|
||||
|
||||
enableMemories: false,
|
||||
allowUpdatingMemories: true
|
||||
}
|
||||
|
||||
export interface IVaultkeeperAISettings {
|
||||
|
|
@ -37,6 +40,9 @@ export interface IVaultkeeperAISettings {
|
|||
|
||||
searchResultsLimit: number;
|
||||
snippetSizeLimit: number;
|
||||
|
||||
enableMemories: boolean;
|
||||
allowUpdatingMemories: boolean;
|
||||
}
|
||||
|
||||
export class SettingsService {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type VaultkeeperAIPlugin from "main";
|
||||
import { Resolve } from "./DependencyService";
|
||||
import { Services } from "./Services";
|
||||
import type { TFile, WorkspaceLeaf } from "obsidian";
|
||||
import { Notice, TFile, type WorkspaceLeaf } from "obsidian";
|
||||
import type { VaultService } from "./VaultService";
|
||||
|
||||
export class WorkSpaceService {
|
||||
|
|
@ -14,6 +14,19 @@ export class WorkSpaceService {
|
|||
|
||||
if (file) {
|
||||
await leaf.openFile(file);
|
||||
} else {
|
||||
new Notice(`Failed to open note: "${noteName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
public async openNoteByPath(path: string) {
|
||||
const file = this.plugin.app.vault.getAbstractFileByPath(path);
|
||||
|
||||
if (file instanceof TFile) {
|
||||
const leaf: WorkspaceLeaf = this.plugin.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(file);
|
||||
} else {
|
||||
new Notice(`Failed to open note: "${path}"`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,14 @@
|
|||
resize: none;
|
||||
}
|
||||
|
||||
.setting-item-memories-disabled .checkbox-container {
|
||||
background-color: var(--interactive-accent-disabled);
|
||||
}
|
||||
|
||||
.setting-item-memories-disabled .setting-item-info {
|
||||
font-weight: var(--font-light);
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* CSS Variables for Common Components */
|
||||
/* ============================== */
|
||||
|
|
@ -205,6 +213,11 @@ body {
|
|||
var(--background-primary) 75%,
|
||||
hsl(0 0% 0%) 25%
|
||||
);
|
||||
--interactive-accent-disabled: color-mix(
|
||||
in hsl shorter hue,
|
||||
var(--background-modifier-border-hover) 50%,
|
||||
var(--interactive-accent) 40%
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
|
|
|
|||
|
|
@ -3,24 +3,34 @@ import { Copy } from "Enums/Copy";
|
|||
import { Selector } from "Enums/Selector";
|
||||
import type VaultkeeperAIPlugin from "main";
|
||||
import { HelpModal } from "Modals/HelpModal";
|
||||
import { DropdownComponent, PluginSettingTab, Setting, setIcon, setTooltip } from "obsidian";
|
||||
import { DropdownComponent, PluginSettingTab, Setting, ToggleComponent, setIcon, setTooltip } from "obsidian";
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
import { Services } from "Services/Services";
|
||||
import { RegisterAiProvider } from "Services/ServiceRegistration";
|
||||
import { closePluginSettings } from "Helpers/Helpers";
|
||||
import type { MemoriesService } from "Services/MemoriesService";
|
||||
|
||||
export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||
private readonly plugin: VaultkeeperAIPlugin;
|
||||
private readonly settingsService: SettingsService;
|
||||
private readonly memoriesService: MemoriesService;
|
||||
|
||||
private apiKeySetting: Setting | null = null;
|
||||
private apiKeyInputEl: HTMLInputElement | null = null;
|
||||
private fileDisclaimerSetting: Setting | null = null;
|
||||
private planningModelDropdown: DropdownComponent | null = null;
|
||||
private allowUpdatingMemoriesSetting: Setting | null = null;
|
||||
private allowUpdatingMemoriesToggleComponent: ToggleComponent | null = null;
|
||||
|
||||
constructor() {
|
||||
const plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
|
||||
super(plugin.app, plugin);
|
||||
this.plugin = plugin;
|
||||
|
||||
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
this.memoriesService = Resolve<MemoriesService>(Services.MemoriesService);
|
||||
}
|
||||
|
||||
public display() {
|
||||
|
|
@ -47,6 +57,20 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
|||
});
|
||||
});
|
||||
|
||||
/* Model files API disclaimer */
|
||||
this.fileDisclaimerSetting = new Setting(containerEl)
|
||||
.setDesc(Copy.SettingFileMonitoringClaude)
|
||||
.addExtraButton(button => {
|
||||
button
|
||||
.setTooltip(Copy.TooltipLearnMoreFileMonitoring)
|
||||
.onClick(() => {
|
||||
const modal = Resolve<HelpModal>(Services.HelpModal);
|
||||
modal.open(2); // Opens HelpModal to "Plugin Guide" (topic 2)
|
||||
});
|
||||
setIcon(button.extraSettingsEl, "help-circle");
|
||||
});
|
||||
this.updateFileDisclaimer();
|
||||
|
||||
/* Planning Model Selection Setting */
|
||||
const currentProvider = fromModel(this.settingsService.settings.model);
|
||||
const planningModelDescFragment = document.createDocumentFragment();
|
||||
|
|
@ -150,24 +174,53 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
|||
});
|
||||
});
|
||||
|
||||
/* File Monitoring Guidelines */
|
||||
/* Memories Header */
|
||||
new Setting(containerEl)
|
||||
.setHeading()
|
||||
.setName(Copy.SettingFileMonitoringHeading);
|
||||
.setName(Copy.SettingMemories);
|
||||
|
||||
this.fileDisclaimerSetting = new Setting(containerEl)
|
||||
.setDesc(Copy.SettingFileMonitoringClaude)
|
||||
.addExtraButton(button => {
|
||||
button
|
||||
.setTooltip(Copy.TooltipLearnMoreFileMonitoring)
|
||||
.onClick(() => {
|
||||
const modal = Resolve<HelpModal>(Services.HelpModal);
|
||||
modal.open(2); // Opens HelpModal to "Plugin Guide" (topic 2)
|
||||
/* Enable Memories Setting */
|
||||
new Setting(containerEl)
|
||||
.setName(Copy.SettingEnableMemories)
|
||||
.setDesc(Copy.SettingEnableMemoriesDesc)
|
||||
.addToggle(toggle => {
|
||||
toggle
|
||||
.setValue(this.settingsService.settings.enableMemories)
|
||||
.onChange(async (value) => {
|
||||
this.settingsService.settings.enableMemories = value;
|
||||
await this.settingsService.saveSettings();
|
||||
this.updateAllowUpdatingMemoriesSetting();
|
||||
});
|
||||
setIcon(button.extraSettingsEl, "help-circle");
|
||||
});
|
||||
|
||||
this.updateFileDisclaimer();
|
||||
/* Allow Updating Memories Setting */
|
||||
this.allowUpdatingMemoriesSetting = new Setting(containerEl)
|
||||
.setName(Copy.SettingAllowUpdatingMemories)
|
||||
.setDesc(Copy.SettingAllowUpdatingMemoriesDesc)
|
||||
.addToggle(toggle => {
|
||||
this.allowUpdatingMemoriesToggleComponent = toggle;
|
||||
toggle
|
||||
.setValue(this.settingsService.settings.allowUpdatingMemories)
|
||||
.onChange(async (value) => {
|
||||
this.settingsService.settings.allowUpdatingMemories = value;
|
||||
await this.settingsService.saveSettings();
|
||||
})
|
||||
});
|
||||
this.updateAllowUpdatingMemoriesSetting();
|
||||
|
||||
/* Access Memories banner */
|
||||
new Setting(containerEl)
|
||||
.setDesc(Copy.SettingAccessMemories)
|
||||
.addExtraButton(button => {
|
||||
button
|
||||
.setTooltip(Copy.TooltipAccessMemories)
|
||||
.onClick(async () => {
|
||||
await this.memoriesService.openMemories();
|
||||
closePluginSettings(this.plugin);
|
||||
});
|
||||
setIcon(button.extraSettingsEl, "clipboard-clock");
|
||||
});
|
||||
this.updateFileDisclaimer();
|
||||
}
|
||||
|
||||
private populateModelDropdown(dropdown: DropdownComponent, providerFilter?: AIProvider): void {
|
||||
|
|
@ -249,6 +302,14 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
|||
}
|
||||
}
|
||||
|
||||
private updateAllowUpdatingMemoriesSetting() {
|
||||
if (this.allowUpdatingMemoriesToggleComponent && this.allowUpdatingMemoriesSetting) {
|
||||
const enabled = this.settingsService.settings.enableMemories;
|
||||
this.allowUpdatingMemoriesToggleComponent.disabled = !enabled;
|
||||
this.allowUpdatingMemoriesSetting.settingEl.toggleClass("setting-item-memories-disabled", !enabled);
|
||||
}
|
||||
}
|
||||
|
||||
private updateFileDisclaimer() {
|
||||
if (this.fileDisclaimerSetting) {
|
||||
const provider = fromModel(this.settingsService.settings.model);
|
||||
|
|
|
|||
Loading…
Reference in a new issue