Skip to content

Commit e693ccc

Browse files
authored
Merge branch 'main' into feature/agent-clarification-questions
2 parents 4b13fdd + 822ffe3 commit e693ccc

44 files changed

Lines changed: 3578 additions & 53 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Search surfaces your notes, the graph groups them into the topics your vault is
2525

2626
- **[Search](https://smartsecondbrain.dev/search/)** that combines keyword matching with meaning, so you find the note you were thinking of rather than the one you typed ([how it works](https://smartsecondbrain.dev/search/how-it-works/))
2727
- **[Smart Graph](https://smartsecondbrain.dev/graph/)** that works out what your vault is about: notes grouped into topics from your links, your tags, and what you wrote, with nothing to maintain ([how it works](https://smartsecondbrain.dev/graph/how-it-works/))
28-
- **[Agents](https://smartsecondbrain.dev/agents/)** that read and write your notes, with skills, memory, MCP, and integrations with your other plugins
28+
- **[Agents](https://smartsecondbrain.dev/agents/)** that read and write your notes, draw charts and dashboards live from them as [widgets](https://smartsecondbrain.dev/agents/widgets/), with skills, memory, MCP, and integrations with your other plugins
2929

3030
## Install
3131

‎bun.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "Search, explore, and chat with your notes using hybrid semantic search, an interactive knowledge graph, and an AI assistant powered by local or cloud providers like Ollama, OpenAI, and Anthropic.",
55
"author": "Leo310, nicobrauchtgit",
66
"authorUrl": "https://smartsecondbrain.dev",
7-
"version": "2.1.2",
7+
"version": "2.2.0",
88
"minAppVersion": "1.11.4",
99
"isDesktopOnly": false
1010
}

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "smart-second-brain",
3-
"version": "2.1.2",
3+
"version": "2.2.0",
44
"main": "main.js",
55
"devDependencies": {
66
"@biomejs/biome": "^1.9.4",
@@ -17,6 +17,7 @@
1717
"fake-indexeddb": "^6.2.5",
1818
"jsdom": "^27.4.0",
1919
"obsidian": "^1.13.1",
20+
"plotly.js-gl3d-dist-min": "^4.1.1",
2021
"postcss": "^8.5.28",
2122
"svelte": "^5.57.0",
2223
"svelte-check": "^4.7.6",

‎src/agent/AgentManager.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ import {
8383
type PluginIntegration,
8484
pluginExposesApi,
8585
isCommunityPluginEnabled,
86+
communityPluginDisplayName,
87+
communityPluginStatus,
8688
isInternalPluginEnabled,
8789
getPluginIcon,
8890
skillIcon,
@@ -966,6 +968,10 @@ export class AgentManager {
966968
if (toolId.startsWith("exec_")) return boundExecTools.has(toolId);
967969
return true;
968970
},
971+
pluginStatus: (pluginId) => ({
972+
status: communityPluginStatus(this.plugin.app, pluginId),
973+
displayName: communityPluginDisplayName(pluginId),
974+
}),
969975
}),
970976
);
971977
}

‎src/agent/integrations/pluginIntegrations.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,26 @@ export function isCommunityPluginEnabled(app: App, pluginId: string): boolean {
7070
return Boolean(app.plugins?.enabledPlugins?.has(pluginId));
7171
}
7272

73+
export type CommunityPluginStatus = "enabled" | "disabled" | "missing";
74+
75+
/**
76+
* Installed-and-enabled / installed-but-disabled / not installed, for a community
77+
* plugin id. "Installed" is read from the manifests Obsidian loaded from the plugins
78+
* folder, so a plugin the user turned off still reads as installed.
79+
*/
80+
export function communityPluginStatus(app: App, pluginId: string): CommunityPluginStatus {
81+
if (isCommunityPluginEnabled(app, pluginId)) return "enabled";
82+
// @ts-ignore - Obsidian plugin API (not in official types)
83+
return app.plugins?.manifests?.[pluginId] ? "disabled" : "missing";
84+
}
85+
86+
/** Human-friendly name for a community plugin id, from the curated table; the id otherwise. */
87+
export function communityPluginDisplayName(pluginId: string): string {
88+
return (
89+
CURATED_PLUGIN_INTEGRATIONS.find((integration) => integration.pluginId === pluginId)?.displayName ?? pluginId
90+
);
91+
}
92+
7393
/**
7494
* Whether an Obsidian core (internal) plugin is enabled (e.g. "canvas", "bases").
7595
* Uses undocumented internal API — may need updates with Obsidian changes.
@@ -179,14 +199,15 @@ const BUNDLED_CORE_SKILL_ICONS: Record<string, string> = {
179199
"manage-notes": "file-pen",
180200
web: "globe",
181201
"manage-skills": "wand-2",
202+
widgets: "component", // = DEFAULT_WIDGET_ICON in widget/widgetIcon.ts
182203
};
183204

184205
/**
185206
* Fixed display order for the S2B built-in core skills (the 4 former capabilities). Used by
186207
* both the agent editor's Core Skills list and the agents-summary icon strip so the two never
187208
* drift. Core-plugin skills (Canvas, Bases, …) are ranked after all of these.
188209
*/
189-
const S2B_CORE_SKILL_ORDER = ["explore-vault", "manage-notes", "web", "manage-skills"];
210+
const S2B_CORE_SKILL_ORDER = ["explore-vault", "manage-notes", "web", "manage-skills", "widgets"];
190211

191212
/**
192213
* Sort rank for a core-category skill: listed S2B built-ins first in `S2B_CORE_SKILL_ORDER`,

‎src/agent/tools/loadSkill.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
import { tool } from "@langchain/core/tools";
22
import { z } from "zod";
33
import type { SkillsService } from "../../skills/SkillsService";
4+
import type { CommunityPluginStatus } from "../integrations/pluginIntegrations";
5+
6+
/** Frontmatter key (under `metadata:`) listing community plugin ids a skill can make use of. */
7+
export const OPTIONAL_PLUGINS_METADATA_KEY = "optionalPlugins";
8+
9+
const STATUS_TEXT: Record<CommunityPluginStatus, string> = {
10+
enabled: "enabled",
11+
disabled: "installed but disabled — the user can enable it under Settings → Community plugins",
12+
missing: "not installed — the user can install it from Settings → Community plugins → Browse",
13+
};
14+
15+
/** Split a space- or comma-separated id list from frontmatter. */
16+
export function parseOptionalPlugins(metadata: Record<string, string> | undefined): string[] {
17+
const raw = metadata?.[OPTIONAL_PLUGINS_METADATA_KEY];
18+
if (!raw) return [];
19+
return raw
20+
.split(/[\s,]+/)
21+
.map((id) => id.trim())
22+
.filter(Boolean);
23+
}
424

525
export interface LoadSkillToolOptions {
626
/**
@@ -17,6 +37,13 @@ export interface LoadSkillToolOptions {
1737
* and without the note the model would call a tool that doesn't exist.
1838
*/
1939
isToolAvailable?: (toolId: string) => boolean;
40+
/**
41+
* Live status of a community plugin a skill lists under `metadata.optionalPlugins`.
42+
* Appended to the loaded body so a skill whose *best* path depends on another
43+
* plugin (the widgets skill's Dataview queries) can tell the model up front whether
44+
* that path exists, instead of letting it find out from a failed result.
45+
*/
46+
pluginStatus?: (pluginId: string) => { status: CommunityPluginStatus; displayName: string };
2047
}
2148

2249
/**
@@ -28,7 +55,7 @@ export interface LoadSkillToolOptions {
2855
* @returns A LangChain tool for loading skill content
2956
*/
3057
export function createLoadSkillTool(skillsService: SkillsService, options: LoadSkillToolOptions) {
31-
const { skillNames, isToolAvailable } = options;
58+
const { skillNames, isToolAvailable, pluginStatus } = options;
3259

3360
// If no skills available, return a tool that explains this
3461
if (skillNames.length === 0) {
@@ -77,6 +104,20 @@ export function createLoadSkillTool(skillsService: SkillsService, options: LoadS
77104
}
78105
}
79106

107+
const optionalPlugins = pluginStatus ? parseOptionalPlugins(skill.frontmatter.metadata) : [];
108+
if (optionalPlugins.length > 0) {
109+
lines.push("");
110+
lines.push("## Plugin availability");
111+
lines.push("");
112+
for (const pluginId of optionalPlugins) {
113+
const { status, displayName } = pluginStatus?.(pluginId) ?? {
114+
status: "missing",
115+
displayName: pluginId,
116+
};
117+
lines.push(`- ${displayName} (\`${pluginId}\`): ${STATUS_TEXT[status]}`);
118+
}
119+
}
120+
80121
lines.push("");
81122
lines.push("## Instructions");
82123
lines.push("");

‎src/agent/tools/manageNotes.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,20 @@ const editSchema = z.object({
3030
.describe("Replace all occurrences instead of requiring a single unique match. Default false."),
3131
});
3232

33+
/** What the agent may create, edit, move and delete: notes, and standalone widgets (`.widget`, see views/widget). */
34+
const AGENT_WRITABLE_EXTENSIONS = [".md", ".widget"];
35+
const isAgentWritablePath = (path: string): boolean => AGENT_WRITABLE_EXTENSIONS.some((ext) => path.endsWith(ext));
36+
3337
const createOperationSchema = z.object({
3438
type: z.literal("create"),
3539
path: z
3640
.string()
37-
.describe("Vault-relative path for the new markdown note. Must end in .md. Example: Notes/my-note.md"),
38-
content: z.string().describe("Full markdown content for the new note"),
41+
.describe(
42+
"Vault-relative path for the new file. Must end in .md (a note) or .widget (a standalone widget: frontmatter, then HTML). Example: Notes/my-note.md",
43+
),
44+
content: z
45+
.string()
46+
.describe("Full content for the new file (markdown for a note; a widget fence's body for a .widget)"),
3947
});
4048

4149
const REPLACE_PENDING_DESCRIPTION =
@@ -65,7 +73,9 @@ const deleteOperationSchema = z.object({
6573
const moveOperationSchema = z.object({
6674
type: z.literal("move"),
6775
path: z.string().describe("Current file path or wiki link reference to move"),
68-
newPath: z.string().describe("Destination vault-relative path for the markdown note. Must end in .md."),
76+
newPath: z
77+
.string()
78+
.describe("Destination vault-relative path. Must end in .md (a note) or .widget (a standalone widget)."),
6979
});
7080

7181
const replaceOperationSchema = z.object({
@@ -147,9 +157,9 @@ function validateExistingMarkdownFile(
147157
}
148158

149159
const file = result.file;
150-
if (!file.path.endsWith(".md")) {
160+
if (!isAgentWritablePath(file.path)) {
151161
return {
152-
error: `Error in operation ${operationNumber}: Only markdown files (.md) can be ${action}d. "${file.path}" is not a markdown file.`,
162+
error: `Error in operation ${operationNumber}: Only notes (.md) and widgets (.widget) can be ${action}d. "${file.path}" is neither.`,
153163
};
154164
}
155165

@@ -510,8 +520,8 @@ async function stageNoteOperations(
510520
const duplicateError = ensureUniqueTarget(seenPaths, normalizedPath, operationNumber);
511521
if (duplicateError) return duplicateError;
512522

513-
if (!normalizedPath.endsWith(".md")) {
514-
return `Error in operation ${operationNumber}: Only markdown files (.md) can be created. Got: "${normalizedPath}"`;
523+
if (!isAgentWritablePath(normalizedPath)) {
524+
return `Error in operation ${operationNumber}: Only notes (.md) and widgets (.widget) can be created. Got: "${normalizedPath}"`;
515525
}
516526

517527
if (!store.isPathAllowed(normalizedPath)) {
@@ -617,8 +627,8 @@ async function stageNoteOperations(
617627
if (sourceDuplicateError) return sourceDuplicateError;
618628

619629
const normalizedNewPath = normalizePath(operation.newPath);
620-
if (!normalizedNewPath.endsWith(".md")) {
621-
return `Error in operation ${operationNumber}: Only markdown files (.md) can be moved. Got destination "${normalizedNewPath}"`;
630+
if (!isAgentWritablePath(normalizedNewPath)) {
631+
return `Error in operation ${operationNumber}: Only notes (.md) and widgets (.widget) can be moved. Got destination "${normalizedNewPath}"`;
622632
}
623633

624634
const destinationDuplicateError = ensureUniqueTarget(seenPaths, normalizedNewPath, operationNumber);

0 commit comments

Comments
 (0)