Skip to content

Instantly share code, notes, and snippets.

@fblissjr
Last active July 28, 2024 23:16
Show Gist options
  • Save fblissjr/597b9aeb6f4f79372643a79be91a41a3 to your computer and use it in GitHub Desktop.
Save fblissjr/597b9aeb6f4f79372643a79be91a41a3 to your computer and use it in GitHub Desktop.
loom technical overview (by claude sonnet 3.5)

Loom Project Detailed Technical Design Overview - written by claude sonnet 3.5

Project Architecture

Loom is built as an Obsidian plugin, following the plugin architecture defined by the Obsidian API. It's written in TypeScript and compiled to JavaScript for use in Obsidian.

Key Files and Their Roles

  1. main.ts: Core plugin logic and Obsidian integration
  2. views.ts: UI components and rendering logic
  3. common.ts: Shared types, interfaces, and utility functions

Detailed Component Breakdown

1. main.ts

The main.ts file contains the LoomPlugin class, which is the entry point for the plugin.

export default class LoomPlugin extends Plugin {
  settings: LoomSettings;
  state: Record<string, NoteState>;
  editor: Editor;
  // ... other properties

  async onload() {
    // Plugin initialization
  }

  // ... other methods
}

Key Methods

  1. onload(): Initializes the plugin

    • Loads settings
    • Registers views (LoomView, LoomSiblingsView)
    • Adds commands
    • Sets up event listeners
  2. complete(file: TFile): Generates completions for the current node

    async complete(file: TFile) {
      const state = this.state[file.path];
      const [parentNode] = this.breakAtPoint(file);
      this.app.workspace.trigger("loom:switch-to", parentNode);
      await this.generate(file, state.current);
    }
  3. generate(file: TFile, rootNode: string | null): Core method for generating content

    • Prepares the prompt
    • Calls the appropriate AI provider
    • Creates new nodes with generated content
  4. AI Provider Methods:

    • completeOpenAI()
    • completeAzure()
    • completeCohere()
    • completeTextSynth()
    • completeAnthropic()

    These methods handle the specifics of each AI provider's API.

  5. State Management Methods:

    • saveAndRender(): Saves the current state and triggers a re-render
    • initializeNoteState(file: TFile): Sets up the initial state for a new note
  6. Node Operations:

    • newNode(text: string, parentId: string | null, unread: boolean = false): [string, Node]
    • breakAtPoint(file: TFile): (string | null)[]
    • mergeWithParent(id: string)

2. views.ts

The views.ts file contains the custom view classes for rendering the Loom interface.

LoomView

export class LoomView extends ItemView {
  getNoteState: () => NoteState | null;
  getSettings: () => LoomSettings;

  constructor(leaf: WorkspaceLeaf, getNoteState: () => NoteState | null, getSettings: () => LoomSettings) {
    super(leaf);
    this.getNoteState = getNoteState;
    this.getSettings = getSettings;
  }

  render() {
    // Rendering logic for the main Loom view
  }

  // ... other methods
}

Key methods:

  • render(): Renders the entire Loom tree structure
  • renderNode(container: HTMLElement, state: NoteState, id: string, inTree: boolean): Renders individual nodes

LoomSiblingsView

Similar to LoomView, but focuses on rendering sibling nodes.

LoomEditorPlugin

Extends the Obsidian editor with Loom-specific features:

export class LoomEditorPlugin implements PluginValue {
  decorations: DecorationSet;
  state: LoomEditorPluginState;

  update() {
    // Update editor decorations
  }
}

3. common.ts

Defines shared types and interfaces:

export type Provider = "openai" | "azure" | "cohere" | "textsynth" | "anthropic";

export interface ModelPreset<P extends Provider> {
  name: string;
  provider: P;
  model: string;
  contextLength: number;
  apiKey: string;
  // ... other properties
}

export interface Node {
  text: string;
  parentId: string | null;
  collapsed: boolean;
  unread: boolean;
  bookmarked: boolean;
  lastVisited?: number;
  searchResultState: SearchResultState;
}

export interface NoteState {
  current: string;
  hoisted: string[];
  searchTerm: string;
  nodes: Record<string, Node>;
  generating: string | null;
}

Data Flow and State Management

  1. The plugin maintains a state object in LoomPlugin, which is a record of NoteState objects keyed by file paths.
  2. When a note is opened or created, initializeNoteState() is called to set up the initial state.
  3. User interactions (e.g., generating completions, navigating nodes) trigger state updates.
  4. After state updates, saveAndRender() is called to persist changes and update the UI.

AI Integration Architecture

  1. The plugin supports multiple AI providers through a unified interface.
  2. Provider-specific logic is encapsulated in separate methods (e.g., completeOpenAI(), completeAzure()).
  3. The generate() method acts as a facade, delegating to the appropriate provider method based on user settings.
async generate(file: TFile, rootNode: string | null) {
  // ... preparation logic
  const completionMethods: Record<Provider, (prompt: string) => Promise<CompletionResult>> = {
    openai: this.completeOpenAI,
    azure: this.completeAzure,
    // ... other providers
  };
  const result = await completionMethods[getPreset(this.settings).provider].bind(this)(prompt);
  // ... handle result
}

LLM Provider Integration Loom's architecture is designed to support multiple LLM providers through a flexible and extensible system. Here's a detailed breakdown of how LLM providers are defined and used in Loom:

  1. Provider Definition LLM providers are primarily defined in the common.ts file: typescriptCopy// common.ts

export type Provider = "openai" | "azure" | "cohere" | "textsynth" | "anthropic";

export interface ModelPreset

{ name: string; provider: P; model: string; contextLength: number; apiKey: string; // Provider-specific properties // e.g., for OpenAI: organization?: string; // for Azure: resourceName?: string; deploymentId?: string; }

export interface LoomSettings { // ... other settings modelPresets: ModelPreset[]; modelPreset: number; // Index of the currently selected preset // ... provider-specific settings openaiApiKey?: string; azureApiKey?: string; // ... etc. } 2. Provider Implementation Each provider has its own implementation method in the LoomPlugin class: typescriptCopy// main.ts

export default class LoomPlugin extends Plugin { // ... other properties and methods

async completeOpenAI(prompt: string): Promise { // Implementation for OpenAI }

async completeAzure(prompt: string): Promise { // Implementation for Azure }

async completeCohere(prompt: string): Promise { // Implementation for Cohere }

// ... methods for other providers } 3. Provider Selection and Use Providers are selected and used in the generate method: typescriptCopy// main.ts

export default class LoomPlugin extends Plugin { // ... other methods

async generate(file: TFile, rootNode: string | null) { // ... preparation logic

const completionMethods: Record<Provider, (prompt: string) => Promise<CompletionResult>> = {
  openai: this.completeOpenAI,
  azure: this.completeAzure,
  cohere: this.completeCohere,
  textsynth: this.completeTextSynth,
  anthropic: this.completeAnthropic,
};

const preset = getPreset(this.settings);
const result = await completionMethods[preset.provider].bind(this)(prompt);

// ... handle result and create nodes

} } 4. Provider Settings Provider-specific settings are managed in the settings tab: typescriptCopy// settings.ts

class LoomSettingTab extends PluginSettingTab { // ... other methods

display(): void { // ... other settings

new Setting(containerEl)
  .setName("Model Preset")
  .setDesc("Select the AI model preset to use")
  .addDropdown(dropdown => {
    this.plugin.settings.modelPresets.forEach((preset, index) => {
      dropdown.addOption(index.toString(), preset.name);
    });
    dropdown.setValue(this.plugin.settings.modelPreset.toString())
      .onChange(async (value) => {
        this.plugin.settings.modelPreset = parseInt(value);
        await this.plugin.saveSettings();
      });
  });

// Add provider-specific settings based on the selected preset
const currentPreset = this.plugin.settings.modelPresets[this.plugin.settings.modelPreset];
if (currentPreset.provider === "openai") {
  new Setting(containerEl)
    .setName("OpenAI API Key")
    .setDesc("Enter your OpenAI API key")
    .addText(text => text
      .setPlaceholder("Enter API key")
      .setValue(this.plugin.settings.openaiApiKey || "")
      .onChange(async (value) => {
        this.plugin.settings.openaiApiKey = value;
        await this.plugin.saveSettings();
      }));
}
// ... similar settings for other providers

} } 5. Adding a New Provider To add a new LLM provider to Loom:

Update the Provider type in common.ts: typescriptCopyexport type Provider = "openai" | "azure" | "cohere" | "textsynth" | "anthropic" | "newProvider";

Add provider-specific properties to ModelPreset if needed: typescriptCopyexport interface ModelPreset

{ // ... existing properties newProviderSpecificProp?: string; }

Implement the completion method in LoomPlugin: typescriptCopyasync completeNewProvider(prompt: string): Promise { // Implementation for the new provider }

Add the new provider to completionMethods in the generate method: typescriptCopyconst completionMethods: Record<Provider, (prompt: string) => Promise> = { // ... existing providers newProvider: this.completeNewProvider, };

Update the settings tab to include options for the new provider. Add presets for the new provider in the plugin's initialization or settings.

[Add the following new section after the LLM Provider Integration section:]

LLM Sampling in Loom

Loom implements various sampling techniques to control the output of language models. This section details how sampling is defined, implemented, and used within the plugin.

1. Sampling Parameters Definition

Sampling parameters are defined in the LoomSettings interface in common.ts:

// common.ts

export interface LoomSettings {
  // ... other settings
  temperature: number;
  topP: number;
  frequencyPenalty: number;
  presencePenalty: number;
  repetitionPenalty: number;
  repetitionContextSize: number;
  maxTokens: number;
  logitBias: Record<string, number> | null;
}

2. Sampling Implementation

Sampling is primarily implemented within each provider's completion method. Here's a generalized example of how sampling parameters are applied:

// main.ts

export default class LoomPlugin extends Plugin {
  // ... other methods

  async completeGenericProvider(prompt: string): Promise<CompletionResult> {
    const preset = getPreset(this.settings);
    const response = await requestUrl({
      url: preset.apiEndpoint,
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${preset.apiKey}`,
      },
      body: JSON.stringify({
        model: preset.model,
        prompt: prompt,
        max_tokens: this.settings.maxTokens,
        temperature: this.settings.temperature,
        top_p: this.settings.topP,
        frequency_penalty: this.settings.frequencyPenalty,
        presence_penalty: this.settings.presencePenalty,
        logit_bias: this.settings.logitBias,
        // Some providers might use different parameter names
        repetition_penalty: this.settings.repetitionPenalty,
        repetition_context_size: this.settings.repetitionContextSize,
      }),
    });

    // Process and return the response
  }
}

3. Sampling Parameter Usage

Sampling parameters are used in the generate method when calling the provider-specific completion methods:

// main.ts

export default class LoomPlugin extends Plugin {
  // ... other methods

  async generate(file: TFile, rootNode: string | null) {
    // ... preparation logic

    const preset = getPreset(this.settings);
    const result = await this.completeGenericProvider(prompt);

    // Process the result and create new nodes
    for (const completion of result.completions) {
      const [newNodeId, newNode] = this.newNode(completion, rootNode, true);
      this.state[file.path].nodes[newNodeId] = newNode;
    }

    // ... further processing
  }
}

4. Sampling Settings UI

Sampling parameters are exposed to the user through the settings UI:

// settings.ts

class LoomSettingTab extends PluginSettingTab {
  // ... other methods

  display(): void {
    // ... other settings

    new Setting(containerEl)
      .setName("Temperature")
      .setDesc("Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.")
      .addSlider(slider => slider
        .setLimits(0, 2, 0.1)
        .setValue(this.plugin.settings.temperature)
        .setDynamicTooltip()
        .onChange(async (value) => {
          this.plugin.settings.temperature = value;
          await this.plugin.saveSettings();
        }));

    new Setting(containerEl)
      .setName("Top P")
      .setDesc("Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered.")
      .addSlider(slider => slider
        .setLimits(0, 1, 0.05)
        .setValue(this.plugin.settings.topP)
        .setDynamicTooltip()
        .onChange(async (value) => {
          this.plugin.settings.topP = value;
          await this.plugin.saveSettings();
        }));

    // ... similar settings for other sampling parameters
  }
}

5. Advanced Sampling Techniques

Loom also implements more advanced sampling techniques:

5.1 Repetition Penalty

// sampling.ts

export function applyRepetitionPenalty(logits: number[], tokenIds: number[], repetitionPenalty: number): number[] {
  for (const tokenId of tokenIds) {
    logits[tokenId] /= repetitionPenalty;
  }
  return logits;
}

5.2 Logit Bias

// sampling.ts

export function applyLogitBias(logits: number[], logitBias: Record<string, number>): number[] {
  for (const [tokenId, bias] of Object.entries(logitBias)) {
    logits[parseInt(tokenId)] += bias;
  }
  return logits;
}

6. Integrating Sampling with Branching

Loom's unique branching feature interacts with sampling in the following way:

// main.ts

export default class LoomPlugin extends Plugin {
  // ... other methods

  async generateBranches(file: TFile, rootNode: string, numBranches: number) {
    const prompt = this.getPromptFromNode(rootNode);
    
    for (let i = 0; i < numBranches; i++) {
      // Slightly vary sampling parameters for each branch
      const branchSettings = { ...this.settings };
      branchSettings.temperature += (Math.random() - 0.5) * 0.1;
      branchSettings.topP += (Math.random() - 0.5) * 0.05;

      const result = await this.completeWithSettings(prompt, branchSettings);
      
      const [branchId, branchNode] = this.newNode(result.completions[0], rootNode, true);
      this.state[file.path].nodes[branchId] = branchNode;
    }

    this.saveAndRender();
  }

  async completeWithSettings(prompt: string, settings: Partial<LoomSettings>): Promise<CompletionResult> {
    // Use provided settings, falling back to default settings if not specified
    const mergedSettings = { ...this.settings, ...settings };
    
    // Call the appropriate provider with merged settings
    const preset = getPreset(mergedSettings);
    return await this.completeGenericProvider(prompt, mergedSettings);
  }
}

This implementation allows Loom to generate multiple branches with slightly different sampling parameters, providing users with a range of completion options to explore.

7. Extensibility for New Sampling Techniques

To add a new sampling technique to Loom:

  1. Add new parameters to the LoomSettings interface in common.ts.
  2. Implement the new sampling technique in sampling.ts.
  3. Integrate the new technique into the provider completion methods.
  4. Add UI controls for the new sampling parameters in the settings tab.
  5. Update the generateBranches method to utilize the new sampling technique if appropriate.

Tree Structure Implementation

The tree structure is implemented using a flat object structure with parent-child relationships:

  1. Each node has a unique id (typically a UUID).
  2. Nodes are stored in the nodes object of NoteState, keyed by their id.
  3. Parent-child relationships are maintained through the parentId property of each node.
  4. The current property of NoteState points to the currently active node.
  5. The hoisted array in NoteState allows for focusing on specific subtrees.

Extensibility Points

  1. Adding a New AI Provider:

    • Add the provider to the Provider type in common.ts
    • Implement a new completion method in LoomPlugin (e.g., completeNewProvider())
    • Update the completionMethods object in the generate() method
    • Add provider-specific settings to LoomSettings and update the settings UI
  2. Creating New Node Operations:

    • Implement the operation logic in LoomPlugin
    • Add a new command in the onload() method
    • Update the UI in LoomView or LoomSiblingsView if necessary
  3. Modifying the Tree Structure:

    • Update the Node interface in common.ts
    • Modify the renderNode() method in LoomView and LoomSiblingsView
    • Update state management methods in LoomPlugin

Performance Considerations

  1. The plugin uses a flat structure for nodes, which allows for O(1) access to any node by ID.
  2. Rendering is optimized to only update changed parts of the tree.
  3. AI requests are asynchronous to prevent UI blocking.

Testing and Debugging

  1. Use the Obsidian Developer Tools (opened with Ctrl+Shift+I) for console logging and debugging.
  2. Implement unit tests for core logic (currently not present in the codebase).
  3. Manual testing in Obsidian is crucial, especially for UI interactions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment