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.
main.ts
: Core plugin logic and Obsidian integrationviews.ts
: UI components and rendering logiccommon.ts
: Shared types, interfaces, and utility functions
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
}
-
onload()
: Initializes the plugin- Loads settings
- Registers views (
LoomView
,LoomSiblingsView
) - Adds commands
- Sets up event listeners
-
complete(file: TFile)
: Generates completions for the current nodeasync 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); }
-
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
-
AI Provider Methods:
completeOpenAI()
completeAzure()
completeCohere()
completeTextSynth()
completeAnthropic()
These methods handle the specifics of each AI provider's API.
-
State Management Methods:
saveAndRender()
: Saves the current state and triggers a re-renderinitializeNoteState(file: TFile)
: Sets up the initial state for a new note
-
Node Operations:
newNode(text: string, parentId: string | null, unread: boolean = false): [string, Node]
breakAtPoint(file: TFile): (string | null)[]
mergeWithParent(id: string)
The views.ts
file contains the custom view classes for rendering the Loom interface.
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 structurerenderNode(container: HTMLElement, state: NoteState, id: string, inTree: boolean)
: Renders individual nodes
Similar to LoomView
, but focuses on rendering sibling nodes.
Extends the Obsidian editor with Loom-specific features:
export class LoomEditorPlugin implements PluginValue {
decorations: DecorationSet;
state: LoomEditorPluginState;
update() {
// Update editor decorations
}
}
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;
}
- The plugin maintains a
state
object inLoomPlugin
, which is a record ofNoteState
objects keyed by file paths. - When a note is opened or created,
initializeNoteState()
is called to set up the initial state. - User interactions (e.g., generating completions, navigating nodes) trigger state updates.
- After state updates,
saveAndRender()
is called to persist changes and update the UI.
- The plugin supports multiple AI providers through a unified interface.
- Provider-specific logic is encapsulated in separate methods (e.g.,
completeOpenAI()
,completeAzure()
). - 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:
- 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:]
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.
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;
}
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
}
}
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
}
}
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
}
}
Loom also implements more advanced sampling techniques:
// sampling.ts
export function applyRepetitionPenalty(logits: number[], tokenIds: number[], repetitionPenalty: number): number[] {
for (const tokenId of tokenIds) {
logits[tokenId] /= repetitionPenalty;
}
return logits;
}
// 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;
}
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.
To add a new sampling technique to Loom:
- Add new parameters to the
LoomSettings
interface incommon.ts
. - Implement the new sampling technique in
sampling.ts
. - Integrate the new technique into the provider completion methods.
- Add UI controls for the new sampling parameters in the settings tab.
- Update the
generateBranches
method to utilize the new sampling technique if appropriate.
The tree structure is implemented using a flat object structure with parent-child relationships:
- Each node has a unique
id
(typically a UUID). - Nodes are stored in the
nodes
object ofNoteState
, keyed by theirid
. - Parent-child relationships are maintained through the
parentId
property of each node. - The
current
property ofNoteState
points to the currently active node. - The
hoisted
array inNoteState
allows for focusing on specific subtrees.
-
Adding a New AI Provider:
- Add the provider to the
Provider
type incommon.ts
- Implement a new completion method in
LoomPlugin
(e.g.,completeNewProvider()
) - Update the
completionMethods
object in thegenerate()
method - Add provider-specific settings to
LoomSettings
and update the settings UI
- Add the provider to the
-
Creating New Node Operations:
- Implement the operation logic in
LoomPlugin
- Add a new command in the
onload()
method - Update the UI in
LoomView
orLoomSiblingsView
if necessary
- Implement the operation logic in
-
Modifying the Tree Structure:
- Update the
Node
interface incommon.ts
- Modify the
renderNode()
method inLoomView
andLoomSiblingsView
- Update state management methods in
LoomPlugin
- Update the
- The plugin uses a flat structure for nodes, which allows for O(1) access to any node by ID.
- Rendering is optimized to only update changed parts of the tree.
- AI requests are asynchronous to prevent UI blocking.
- Use the Obsidian Developer Tools (opened with Ctrl+Shift+I) for console logging and debugging.
- Implement unit tests for core logic (currently not present in the codebase).
- Manual testing in Obsidian is crucial, especially for UI interactions.