Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twist-prompt-docs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: getTwistDocumentation() (twist-scoped LLM documentation that omits connector-only modules) and TWIST_EXEMPLARS (complete example twists, compiled and type-checked on every build) for generation prompts.
53 changes: 52 additions & 1 deletion twister/prebuild.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

Expand DownExpand Up@@ -189,6 +196,50 @@ export default ${JSON.stringify(twistsTemplateContent)};
);
}

// Generate twist-exemplars.ts from src/exemplars/*.ts — complete example
// twists embedded for LLM generation prompts. Each file must start with a
// /* SPEC: ... */ block comment (the user-style specification it implements).
const exemplarsDir = join(srcDir, "exemplars");
if (!existsSync(exemplarsDir)) {
throw new Error("prebuild: src/exemplars/ is missing");
}
const exemplarFiles = readdirSync(exemplarsDir)
.filter((f) => f.endsWith(".ts"))
.sort();
if (exemplarFiles.length === 0) {
throw new Error("prebuild: src/exemplars/ contains no exemplars");
}
const exemplarSections = exemplarFiles.map((file) => {
const raw = readFileSync(join(exemplarsDir, file), "utf-8");
const specMatch = raw.match(/^\/\*\s*SPEC:\s*\n([\s\S]*?)\*\/\s*\n/);
if (!specMatch) {
throw new Error(`prebuild: ${file} is missing its leading /* SPEC: */ comment`);
}
const spec = specMatch[1].trim();
const implementation = raw.slice(specMatch[0].length).trim();
const title = file
.replace(/\.ts$/, "")
.split("-")
// Word-capitalize each hyphen segment, except known acronyms (e.g. "ai"
// -> "AI" for ai-responder.ts) which are upper-cased outright.
.map((w) =>
w.toLowerCase() === "ai" ? "AI" : w[0].toUpperCase() + w.slice(1)
)
.join(" ");
return `## Example: ${title}\n\n### Specification\n\n${spec}\n\n### Implementation\n\n\`\`\`typescript\n${implementation}\n\`\`\``;
});
const exemplarsContent = `/**
* Generated example twists for LLM generation prompts.
*
* This file is auto-generated during build. Do not edit manually.
* Generated from: src/exemplars/*.ts
*/

export default ${JSON.stringify(exemplarSections.join("\n\n"))};
`;
writeFileSync(join(llmDocsDir, "twist-exemplars.ts"), exemplarsContent, "utf-8");
console.log(`✓ Generated twist-exemplars.ts from ${exemplarFiles.length} exemplars`);

console.log(
`✓ Generated ${typeFiles.length} LLM documentation files in src/llm-docs/`
);
39 changes: 39 additions & 0 deletions twister/src/creator-docs.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import llmDocs from "./llm-docs/index.js";
import twistExemplars from "./llm-docs/twist-exemplars.js";

/**
* Gets complete Twist Creator type definitions with import paths for LLM context.
Expand DownExpand Up@@ -27,3 +28,41 @@ export function getBuilderDocumentation(): string {

return documentation;
}

// Modules excluded from TWIST generation docs: twists must never extend
// Connector, and the mail-protocol tools are niche enough to dilute the
// prompt more than they help.
const TWIST_DOC_EXCLUSIONS = new Set([
"@plotday/twister/connector",
"@plotday/twister/tools/imap",
"@plotday/twister/tools/smtp",
]);

/**
* Twist-scoped variant of getBuilderDocumentation(): the same formatted
* type definitions, minus modules that are irrelevant (or misleading) when
* generating a twist.
*/
export function getTwistDocumentation(): string {
let documentation = "# Plot Twist Creator Type Definitions\n\n";
documentation +=
"Complete type definitions with JSDoc documentation for all Plot Twist Creator types.\n";
documentation +=
"These are the source files - use the import paths shown to import types in your twist code.\n\n";
for (const [importPath, content] of Object.entries(llmDocs)) {
if (TWIST_DOC_EXCLUSIONS.has(importPath)) continue;
documentation += `## ${importPath}\n\n`;
documentation += "```typescript\n";
documentation += `// Import from: ${importPath}\n\n`;
documentation += content;
documentation += "\n```\n\n";
}
return documentation;
}

/**
* Complete example twists (specification + implementation pairs) for LLM
* generation prompts. The examples are real compiled sources in
* src/exemplars/, so they are type-checked on every build.
*/
export const TWIST_EXEMPLARS: string = twistExemplars;
60 changes: 60 additions & 0 deletions twister/src/exemplars/ai-responder.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
/* SPEC:
When someone writes a journal note in another language, reply in the same
thread with a short plain-English summary of what they wrote, so they can
check their own understanding. Don't react to notes written by automations.
*/
import { ActorType, Twist, type Note, type ToolBuilder } from "@plotday/twister";
import { AI } from "@plotday/twister/tools/ai";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class LanguageJournal extends Twist<LanguageJournal> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
// onNoteCreated only fires for notes on threads this twist created,
// so Create access is required even though nothing else reads or
// updates other threads.
thread: { access: ThreadAccess.Create },
}),
ai: build(AI),
};
}

async activate() {
// Seed the standing thread the user writes journal entries into.
await this.tools.plot.createThread({
title: "Language journal",
notes: [
{
content:
"Write your journal entries here in any language — I'll reply with a plain-English summary.",
},
],
});
}

// Fires for every new note on a thread this twist created. Guard against
// notes from twists/automations so we never loop on our own replies.
async onNoteCreated(note: Note): Promise<void> {
if (note.author.type === ActorType.Twist) {
return;
}
if (!note.content || note.content.trim().length === 0) {
return;
}

const response = await this.tools.ai.prompt({
model: { speed: "fast", cost: "low" },
prompt: `Summarize this journal entry in one or two plain-English sentences:\n\n${note.content}`,
});
if (!response.text) {
return;
}

// Reply in the same thread the note belongs to.
await this.tools.plot.createNote({
thread: { id: note.thread.id },
content: `English summary: ${response.text}`,
});
}
}
84 changes: 84 additions & 0 deletions twister/src/exemplars/authenticated-sync.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
/* SPEC:
Connect to my bookmarking service account. Once connected, import my starred
bookmarks as threads (title + link note), and check for new ones every hour.
Imported bookmarks must not duplicate on re-sync.
*/
import { ActionType, Twist, type ToolBuilder, type Uuid } from "@plotday/twister";
import { Options } from "@plotday/twister/options";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class BookmarkSync extends Twist<BookmarkSync> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
// Plain twists can't hold OAuth tokens — that machinery
// (provider/scopes/channels) belongs to Connectors. A secure Options
// field is the twist-safe way to let the user "connect" an account
// for a twist to call directly.
options: build(Options, {
apiKey: {
type: "text",
label: "Bookmarking service API key",
default: "",
secure: true,
},
}),
network: build(Network, {
urls: ["https://api.bookmarks.example/*"],
}),
};
}

async activate() {
// Re-runs hourly under a stable key; survives restarts and upgrades.
await this.scheduleRecurring(
"hourly-sync",
await this.callback(this.sync),
{ intervalMs: 60 * 60 * 1000 }
);
await this.sync();
}

async sync(): Promise<void> {
const { apiKey } = this.tools.options;
if (!apiKey) {
return; // Not connected yet; nothing to sync.
}

const response = await fetch("https://api.bookmarks.example/v1/starred", {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
return;
}
const { bookmarks } = (await response.json()) as {
bookmarks: Array<{ id: string; title: string; url: string }>;
};

// Threads a twist creates have no external Link (that's a Connector
// concept), so dedup is tracked manually: store the bookmark id -> thread
// id mapping and skip any bookmark that's already been imported.
for (const bookmark of bookmarks) {
const mappingKey = `bookmark:${bookmark.id}`;
if (await this.get<Uuid>(mappingKey)) {
continue;
}

const threadId = await this.tools.plot.createThread({
title: bookmark.title,
notes: [
{
content: bookmark.url,
actions: [
{ type: ActionType.external, title: "Open bookmark", url: bookmark.url },
],
},
],
});
await this.set(mappingKey, threadId);
}
}
}
61 changes: 61 additions & 0 deletions twister/src/exemplars/scheduled-digest.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
/* SPEC:
Every morning, post a thread with today's weather forecast for my city so I
can plan the day. One thread per day, titled with the date.
*/
import { Twist, type ToolBuilder } from "@plotday/twister";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class WeatherDigest extends Twist<WeatherDigest> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
network: build(Network, {
urls: ["https://api.open-meteo.com/*"],
}),
};
}

async activate() {
await this.scheduleRecurring(
"morning-digest",
await this.callback(this.postDigest),
{ intervalMs: 24 * 60 * 60 * 1000 }
);
}

async postDigest(): Promise<void> {
// Scheduled callbacks can fire more than once (at-least-once delivery),
// so guard "one thread per day" with a stored marker keyed on the date.
const today = new Date().toISOString().slice(0, 10);
const postedKey = `posted:${today}`;
if (await this.get<boolean>(postedKey)) {
return;
}

const response = await fetch(
"https://api.open-meteo.com/v1/forecast?latitude=43.65&longitude=-79.38&daily=temperature_2m_max,precipitation_probability_mean&timezone=auto&forecast_days=1"
);
if (!response.ok) {
return;
}
const data = (await response.json()) as {
daily: {
temperature_2m_max: number[];
precipitation_probability_mean: number[];
};
};

await this.tools.plot.createThread({
title: `Weather for ${today}`,
notes: [
{
content: `High of ${data.daily.temperature_2m_max[0]}°C, ${data.daily.precipitation_probability_mean[0]}% chance of rain.`,
},
],
});
await this.set(postedKey, true);
}
}
13 changes: 12 additions & 1 deletion twister/tsconfig.build.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,18 @@
"declarationMap": true,
"sourceMap": true,
"noEmit": false,
"composite": false
"composite": false,
// src/exemplars/*.ts import "@plotday/twister" by package name (so the
// embedded LLM-facing examples show real import paths, not relative
// ones). Resolving that self-reference requires the same
// moduleResolution + custom condition tsconfig.base.json already grants
// downstream twist/connector projects for the reverse case (resolving
// @plotday/twister straight to this package's src/*). Scoped to this
// build-only config (not tsconfig.json / tsconfig.cli.json) since
// "bundler" resolution is incompatible with tsconfig.cli.json's
// CommonJS output (TS5095).
"moduleResolution": "bundler",
"customConditions": ["@plotday/connector"]
},
"include": ["src/**/*.ts"],
"exclude": [
Expand Down
3 changes: 2 additions & 1 deletion twister/typedoc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,12 @@
"src/utils/hash.ts"
],
"out": "dist/docs",
"tsconfig": "./tsconfig.json",
"tsconfig": "./tsconfig.build.json",
"exclude": [
"**/*+(.spec|.test).ts",
"**/llm-docs/**",
"**/cli/**",
"**/exemplars/**",
"src/twist-guide.ts",
"src/creator-docs.ts",
"prebuild.ts",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(twister): twist-scoped LLM docs and compiled example twists by KrisBraun · Pull Request #277 · plotday/plot · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twist-prompt-docs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: getTwistDocumentation() (twist-scoped LLM documentation that omits connector-only modules) and TWIST_EXEMPLARS (complete example twists, compiled and type-checked on every build) for generation prompts.
53 changes: 52 additions & 1 deletion twister/prebuild.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

Expand DownExpand Up@@ -189,6 +196,50 @@ export default ${JSON.stringify(twistsTemplateContent)};
);
}

// Generate twist-exemplars.ts from src/exemplars/*.ts — complete example
// twists embedded for LLM generation prompts. Each file must start with a
// /* SPEC: ... */ block comment (the user-style specification it implements).
const exemplarsDir = join(srcDir, "exemplars");
if (!existsSync(exemplarsDir)) {
throw new Error("prebuild: src/exemplars/ is missing");
}
const exemplarFiles = readdirSync(exemplarsDir)
.filter((f) => f.endsWith(".ts"))
.sort();
if (exemplarFiles.length === 0) {
throw new Error("prebuild: src/exemplars/ contains no exemplars");
}
const exemplarSections = exemplarFiles.map((file) => {
const raw = readFileSync(join(exemplarsDir, file), "utf-8");
const specMatch = raw.match(/^\/\*\s*SPEC:\s*\n([\s\S]*?)\*\/\s*\n/);
if (!specMatch) {
throw new Error(`prebuild: ${file} is missing its leading /* SPEC: */ comment`);
}
const spec = specMatch[1].trim();
const implementation = raw.slice(specMatch[0].length).trim();
const title = file
.replace(/\.ts$/, "")
.split("-")
// Word-capitalize each hyphen segment, except known acronyms (e.g. "ai"
// -> "AI" for ai-responder.ts) which are upper-cased outright.
.map((w) =>
w.toLowerCase() === "ai" ? "AI" : w[0].toUpperCase() + w.slice(1)
)
.join(" ");
return `## Example: ${title}\n\n### Specification\n\n${spec}\n\n### Implementation\n\n\`\`\`typescript\n${implementation}\n\`\`\``;
});
const exemplarsContent = `/**
* Generated example twists for LLM generation prompts.
*
* This file is auto-generated during build. Do not edit manually.
* Generated from: src/exemplars/*.ts
*/

export default ${JSON.stringify(exemplarSections.join("\n\n"))};
`;
writeFileSync(join(llmDocsDir, "twist-exemplars.ts"), exemplarsContent, "utf-8");
console.log(`✓ Generated twist-exemplars.ts from ${exemplarFiles.length} exemplars`);

console.log(
`✓ Generated ${typeFiles.length} LLM documentation files in src/llm-docs/`
);
39 changes: 39 additions & 0 deletions twister/src/creator-docs.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import llmDocs from "./llm-docs/index.js";
import twistExemplars from "./llm-docs/twist-exemplars.js";

/**
* Gets complete Twist Creator type definitions with import paths for LLM context.
Expand DownExpand Up@@ -27,3 +28,41 @@ export function getBuilderDocumentation(): string {

return documentation;
}

// Modules excluded from TWIST generation docs: twists must never extend
// Connector, and the mail-protocol tools are niche enough to dilute the
// prompt more than they help.
const TWIST_DOC_EXCLUSIONS = new Set([
"@plotday/twister/connector",
"@plotday/twister/tools/imap",
"@plotday/twister/tools/smtp",
]);

/**
* Twist-scoped variant of getBuilderDocumentation(): the same formatted
* type definitions, minus modules that are irrelevant (or misleading) when
* generating a twist.
*/
export function getTwistDocumentation(): string {
let documentation = "# Plot Twist Creator Type Definitions\n\n";
documentation +=
"Complete type definitions with JSDoc documentation for all Plot Twist Creator types.\n";
documentation +=
"These are the source files - use the import paths shown to import types in your twist code.\n\n";
for (const [importPath, content] of Object.entries(llmDocs)) {
if (TWIST_DOC_EXCLUSIONS.has(importPath)) continue;
documentation += `## ${importPath}\n\n`;
documentation += "```typescript\n";
documentation += `// Import from: ${importPath}\n\n`;
documentation += content;
documentation += "\n```\n\n";
}
return documentation;
}

/**
* Complete example twists (specification + implementation pairs) for LLM
* generation prompts. The examples are real compiled sources in
* src/exemplars/, so they are type-checked on every build.
*/
export const TWIST_EXEMPLARS: string = twistExemplars;
60 changes: 60 additions & 0 deletions twister/src/exemplars/ai-responder.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
/* SPEC:
When someone writes a journal note in another language, reply in the same
thread with a short plain-English summary of what they wrote, so they can
check their own understanding. Don't react to notes written by automations.
*/
import { ActorType, Twist, type Note, type ToolBuilder } from "@plotday/twister";
import { AI } from "@plotday/twister/tools/ai";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class LanguageJournal extends Twist<LanguageJournal> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
// onNoteCreated only fires for notes on threads this twist created,
// so Create access is required even though nothing else reads or
// updates other threads.
thread: { access: ThreadAccess.Create },
}),
ai: build(AI),
};
}

async activate() {
// Seed the standing thread the user writes journal entries into.
await this.tools.plot.createThread({
title: "Language journal",
notes: [
{
content:
"Write your journal entries here in any language — I'll reply with a plain-English summary.",
},
],
});
}

// Fires for every new note on a thread this twist created. Guard against
// notes from twists/automations so we never loop on our own replies.
async onNoteCreated(note: Note): Promise<void> {
if (note.author.type === ActorType.Twist) {
return;
}
if (!note.content || note.content.trim().length === 0) {
return;
}

const response = await this.tools.ai.prompt({
model: { speed: "fast", cost: "low" },
prompt: `Summarize this journal entry in one or two plain-English sentences:\n\n${note.content}`,
});
if (!response.text) {
return;
}

// Reply in the same thread the note belongs to.
await this.tools.plot.createNote({
thread: { id: note.thread.id },
content: `English summary: ${response.text}`,
});
}
}
84 changes: 84 additions & 0 deletions twister/src/exemplars/authenticated-sync.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
/* SPEC:
Connect to my bookmarking service account. Once connected, import my starred
bookmarks as threads (title + link note), and check for new ones every hour.
Imported bookmarks must not duplicate on re-sync.
*/
import { ActionType, Twist, type ToolBuilder, type Uuid } from "@plotday/twister";
import { Options } from "@plotday/twister/options";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class BookmarkSync extends Twist<BookmarkSync> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
// Plain twists can't hold OAuth tokens — that machinery
// (provider/scopes/channels) belongs to Connectors. A secure Options
// field is the twist-safe way to let the user "connect" an account
// for a twist to call directly.
options: build(Options, {
apiKey: {
type: "text",
label: "Bookmarking service API key",
default: "",
secure: true,
},
}),
network: build(Network, {
urls: ["https://api.bookmarks.example/*"],
}),
};
}

async activate() {
// Re-runs hourly under a stable key; survives restarts and upgrades.
await this.scheduleRecurring(
"hourly-sync",
await this.callback(this.sync),
{ intervalMs: 60 * 60 * 1000 }
);
await this.sync();
}

async sync(): Promise<void> {
const { apiKey } = this.tools.options;
if (!apiKey) {
return; // Not connected yet; nothing to sync.
}

const response = await fetch("https://api.bookmarks.example/v1/starred", {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
return;
}
const { bookmarks } = (await response.json()) as {
bookmarks: Array<{ id: string; title: string; url: string }>;
};

// Threads a twist creates have no external Link (that's a Connector
// concept), so dedup is tracked manually: store the bookmark id -> thread
// id mapping and skip any bookmark that's already been imported.
for (const bookmark of bookmarks) {
const mappingKey = `bookmark:${bookmark.id}`;
if (await this.get<Uuid>(mappingKey)) {
continue;
}

const threadId = await this.tools.plot.createThread({
title: bookmark.title,
notes: [
{
content: bookmark.url,
actions: [
{ type: ActionType.external, title: "Open bookmark", url: bookmark.url },
],
},
],
});
await this.set(mappingKey, threadId);
}
}
}
61 changes: 61 additions & 0 deletions twister/src/exemplars/scheduled-digest.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
/* SPEC:
Every morning, post a thread with today's weather forecast for my city so I
can plan the day. One thread per day, titled with the date.
*/
import { Twist, type ToolBuilder } from "@plotday/twister";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class WeatherDigest extends Twist<WeatherDigest> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
network: build(Network, {
urls: ["https://api.open-meteo.com/*"],
}),
};
}

async activate() {
await this.scheduleRecurring(
"morning-digest",
await this.callback(this.postDigest),
{ intervalMs: 24 * 60 * 60 * 1000 }
);
}

async postDigest(): Promise<void> {
// Scheduled callbacks can fire more than once (at-least-once delivery),
// so guard "one thread per day" with a stored marker keyed on the date.
const today = new Date().toISOString().slice(0, 10);
const postedKey = `posted:${today}`;
if (await this.get<boolean>(postedKey)) {
return;
}

const response = await fetch(
"https://api.open-meteo.com/v1/forecast?latitude=43.65&longitude=-79.38&daily=temperature_2m_max,precipitation_probability_mean&timezone=auto&forecast_days=1"
);
if (!response.ok) {
return;
}
const data = (await response.json()) as {
daily: {
temperature_2m_max: number[];
precipitation_probability_mean: number[];
};
};

await this.tools.plot.createThread({
title: `Weather for ${today}`,
notes: [
{
content: `High of ${data.daily.temperature_2m_max[0]}°C, ${data.daily.precipitation_probability_mean[0]}% chance of rain.`,
},
],
});
await this.set(postedKey, true);
}
}
13 changes: 12 additions & 1 deletion twister/tsconfig.build.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,18 @@
"declarationMap": true,
"sourceMap": true,
"noEmit": false,
"composite": false
"composite": false,
// src/exemplars/*.ts import "@plotday/twister" by package name (so the
// embedded LLM-facing examples show real import paths, not relative
// ones). Resolving that self-reference requires the same
// moduleResolution + custom condition tsconfig.base.json already grants
// downstream twist/connector projects for the reverse case (resolving
// @plotday/twister straight to this package's src/*). Scoped to this
// build-only config (not tsconfig.json / tsconfig.cli.json) since
// "bundler" resolution is incompatible with tsconfig.cli.json's
// CommonJS output (TS5095).
"moduleResolution": "bundler",
"customConditions": ["@plotday/connector"]
},
"include": ["src/**/*.ts"],
"exclude": [
Expand Down
3 changes: 2 additions & 1 deletion twister/typedoc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,12 @@
"src/utils/hash.ts"
],
"out": "dist/docs",
"tsconfig": "./tsconfig.json",
"tsconfig": "./tsconfig.build.json",
"exclude": [
"**/*+(.spec|.test).ts",
"**/llm-docs/**",
"**/cli/**",
"**/exemplars/**",
"src/twist-guide.ts",
"src/creator-docs.ts",
"prebuild.ts",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(twister): twist-scoped LLM docs and compiled example twists by KrisBraun · Pull Request #277 · plotday/plot · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twist-prompt-docs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: getTwistDocumentation() (twist-scoped LLM documentation that omits connector-only modules) and TWIST_EXEMPLARS (complete example twists, compiled and type-checked on every build) for generation prompts.
53 changes: 52 additions & 1 deletion twister/prebuild.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

Expand DownExpand Up@@ -189,6 +196,50 @@ export default ${JSON.stringify(twistsTemplateContent)};
);
}

// Generate twist-exemplars.ts from src/exemplars/*.ts — complete example
// twists embedded for LLM generation prompts. Each file must start with a
// /* SPEC: ... */ block comment (the user-style specification it implements).
const exemplarsDir = join(srcDir, "exemplars");
if (!existsSync(exemplarsDir)) {
throw new Error("prebuild: src/exemplars/ is missing");
}
const exemplarFiles = readdirSync(exemplarsDir)
.filter((f) => f.endsWith(".ts"))
.sort();
if (exemplarFiles.length === 0) {
throw new Error("prebuild: src/exemplars/ contains no exemplars");
}
const exemplarSections = exemplarFiles.map((file) => {
const raw = readFileSync(join(exemplarsDir, file), "utf-8");
const specMatch = raw.match(/^\/\*\s*SPEC:\s*\n([\s\S]*?)\*\/\s*\n/);
if (!specMatch) {
throw new Error(`prebuild: ${file} is missing its leading /* SPEC: */ comment`);
}
const spec = specMatch[1].trim();
const implementation = raw.slice(specMatch[0].length).trim();
const title = file
.replace(/\.ts$/, "")
.split("-")
// Word-capitalize each hyphen segment, except known acronyms (e.g. "ai"
// -> "AI" for ai-responder.ts) which are upper-cased outright.
.map((w) =>
w.toLowerCase() === "ai" ? "AI" : w[0].toUpperCase() + w.slice(1)
)
.join(" ");
return `## Example: ${title}\n\n### Specification\n\n${spec}\n\n### Implementation\n\n\`\`\`typescript\n${implementation}\n\`\`\``;
});
const exemplarsContent = `/**
* Generated example twists for LLM generation prompts.
*
* This file is auto-generated during build. Do not edit manually.
* Generated from: src/exemplars/*.ts
*/

export default ${JSON.stringify(exemplarSections.join("\n\n"))};
`;
writeFileSync(join(llmDocsDir, "twist-exemplars.ts"), exemplarsContent, "utf-8");
console.log(`✓ Generated twist-exemplars.ts from ${exemplarFiles.length} exemplars`);

console.log(
`✓ Generated ${typeFiles.length} LLM documentation files in src/llm-docs/`
);
39 changes: 39 additions & 0 deletions twister/src/creator-docs.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import llmDocs from "./llm-docs/index.js";
import twistExemplars from "./llm-docs/twist-exemplars.js";

/**
* Gets complete Twist Creator type definitions with import paths for LLM context.
Expand DownExpand Up@@ -27,3 +28,41 @@ export function getBuilderDocumentation(): string {

return documentation;
}

// Modules excluded from TWIST generation docs: twists must never extend
// Connector, and the mail-protocol tools are niche enough to dilute the
// prompt more than they help.
const TWIST_DOC_EXCLUSIONS = new Set([
"@plotday/twister/connector",
"@plotday/twister/tools/imap",
"@plotday/twister/tools/smtp",
]);

/**
* Twist-scoped variant of getBuilderDocumentation(): the same formatted
* type definitions, minus modules that are irrelevant (or misleading) when
* generating a twist.
*/
export function getTwistDocumentation(): string {
let documentation = "# Plot Twist Creator Type Definitions\n\n";
documentation +=
"Complete type definitions with JSDoc documentation for all Plot Twist Creator types.\n";
documentation +=
"These are the source files - use the import paths shown to import types in your twist code.\n\n";
for (const [importPath, content] of Object.entries(llmDocs)) {
if (TWIST_DOC_EXCLUSIONS.has(importPath)) continue;
documentation += `## ${importPath}\n\n`;
documentation += "```typescript\n";
documentation += `// Import from: ${importPath}\n\n`;
documentation += content;
documentation += "\n```\n\n";
}
return documentation;
}

/**
* Complete example twists (specification + implementation pairs) for LLM
* generation prompts. The examples are real compiled sources in
* src/exemplars/, so they are type-checked on every build.
*/
export const TWIST_EXEMPLARS: string = twistExemplars;
60 changes: 60 additions & 0 deletions twister/src/exemplars/ai-responder.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
/* SPEC:
When someone writes a journal note in another language, reply in the same
thread with a short plain-English summary of what they wrote, so they can
check their own understanding. Don't react to notes written by automations.
*/
import { ActorType, Twist, type Note, type ToolBuilder } from "@plotday/twister";
import { AI } from "@plotday/twister/tools/ai";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class LanguageJournal extends Twist<LanguageJournal> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
// onNoteCreated only fires for notes on threads this twist created,
// so Create access is required even though nothing else reads or
// updates other threads.
thread: { access: ThreadAccess.Create },
}),
ai: build(AI),
};
}

async activate() {
// Seed the standing thread the user writes journal entries into.
await this.tools.plot.createThread({
title: "Language journal",
notes: [
{
content:
"Write your journal entries here in any language — I'll reply with a plain-English summary.",
},
],
});
}

// Fires for every new note on a thread this twist created. Guard against
// notes from twists/automations so we never loop on our own replies.
async onNoteCreated(note: Note): Promise<void> {
if (note.author.type === ActorType.Twist) {
return;
}
if (!note.content || note.content.trim().length === 0) {
return;
}

const response = await this.tools.ai.prompt({
model: { speed: "fast", cost: "low" },
prompt: `Summarize this journal entry in one or two plain-English sentences:\n\n${note.content}`,
});
if (!response.text) {
return;
}

// Reply in the same thread the note belongs to.
await this.tools.plot.createNote({
thread: { id: note.thread.id },
content: `English summary: ${response.text}`,
});
}
}
84 changes: 84 additions & 0 deletions twister/src/exemplars/authenticated-sync.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
/* SPEC:
Connect to my bookmarking service account. Once connected, import my starred
bookmarks as threads (title + link note), and check for new ones every hour.
Imported bookmarks must not duplicate on re-sync.
*/
import { ActionType, Twist, type ToolBuilder, type Uuid } from "@plotday/twister";
import { Options } from "@plotday/twister/options";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class BookmarkSync extends Twist<BookmarkSync> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
// Plain twists can't hold OAuth tokens — that machinery
// (provider/scopes/channels) belongs to Connectors. A secure Options
// field is the twist-safe way to let the user "connect" an account
// for a twist to call directly.
options: build(Options, {
apiKey: {
type: "text",
label: "Bookmarking service API key",
default: "",
secure: true,
},
}),
network: build(Network, {
urls: ["https://api.bookmarks.example/*"],
}),
};
}

async activate() {
// Re-runs hourly under a stable key; survives restarts and upgrades.
await this.scheduleRecurring(
"hourly-sync",
await this.callback(this.sync),
{ intervalMs: 60 * 60 * 1000 }
);
await this.sync();
}

async sync(): Promise<void> {
const { apiKey } = this.tools.options;
if (!apiKey) {
return; // Not connected yet; nothing to sync.
}

const response = await fetch("https://api.bookmarks.example/v1/starred", {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
return;
}
const { bookmarks } = (await response.json()) as {
bookmarks: Array<{ id: string; title: string; url: string }>;
};

// Threads a twist creates have no external Link (that's a Connector
// concept), so dedup is tracked manually: store the bookmark id -> thread
// id mapping and skip any bookmark that's already been imported.
for (const bookmark of bookmarks) {
const mappingKey = `bookmark:${bookmark.id}`;
if (await this.get<Uuid>(mappingKey)) {
continue;
}

const threadId = await this.tools.plot.createThread({
title: bookmark.title,
notes: [
{
content: bookmark.url,
actions: [
{ type: ActionType.external, title: "Open bookmark", url: bookmark.url },
],
},
],
});
await this.set(mappingKey, threadId);
}
}
}
61 changes: 61 additions & 0 deletions twister/src/exemplars/scheduled-digest.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
/* SPEC:
Every morning, post a thread with today's weather forecast for my city so I
can plan the day. One thread per day, titled with the date.
*/
import { Twist, type ToolBuilder } from "@plotday/twister";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class WeatherDigest extends Twist<WeatherDigest> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
network: build(Network, {
urls: ["https://api.open-meteo.com/*"],
}),
};
}

async activate() {
await this.scheduleRecurring(
"morning-digest",
await this.callback(this.postDigest),
{ intervalMs: 24 * 60 * 60 * 1000 }
);
}

async postDigest(): Promise<void> {
// Scheduled callbacks can fire more than once (at-least-once delivery),
// so guard "one thread per day" with a stored marker keyed on the date.
const today = new Date().toISOString().slice(0, 10);
const postedKey = `posted:${today}`;
if (await this.get<boolean>(postedKey)) {
return;
}

const response = await fetch(
"https://api.open-meteo.com/v1/forecast?latitude=43.65&longitude=-79.38&daily=temperature_2m_max,precipitation_probability_mean&timezone=auto&forecast_days=1"
);
if (!response.ok) {
return;
}
const data = (await response.json()) as {
daily: {
temperature_2m_max: number[];
precipitation_probability_mean: number[];
};
};

await this.tools.plot.createThread({
title: `Weather for ${today}`,
notes: [
{
content: `High of ${data.daily.temperature_2m_max[0]}°C, ${data.daily.precipitation_probability_mean[0]}% chance of rain.`,
},
],
});
await this.set(postedKey, true);
}
}
13 changes: 12 additions & 1 deletion twister/tsconfig.build.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,18 @@
"declarationMap": true,
"sourceMap": true,
"noEmit": false,
"composite": false
"composite": false,
// src/exemplars/*.ts import "@plotday/twister" by package name (so the
// embedded LLM-facing examples show real import paths, not relative
// ones). Resolving that self-reference requires the same
// moduleResolution + custom condition tsconfig.base.json already grants
// downstream twist/connector projects for the reverse case (resolving
// @plotday/twister straight to this package's src/*). Scoped to this
// build-only config (not tsconfig.json / tsconfig.cli.json) since
// "bundler" resolution is incompatible with tsconfig.cli.json's
// CommonJS output (TS5095).
"moduleResolution": "bundler",
"customConditions": ["@plotday/connector"]
},
"include": ["src/**/*.ts"],
"exclude": [
Expand Down
3 changes: 2 additions & 1 deletion twister/typedoc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,12 @@
"src/utils/hash.ts"
],
"out": "dist/docs",
"tsconfig": "./tsconfig.json",
"tsconfig": "./tsconfig.build.json",
"exclude": [
"**/*+(.spec|.test).ts",
"**/llm-docs/**",
"**/cli/**",
"**/exemplars/**",
"src/twist-guide.ts",
"src/creator-docs.ts",
"prebuild.ts",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(twister): twist-scoped LLM docs and compiled example twists by KrisBraun · Pull Request #277 · plotday/plot · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twist-prompt-docs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: getTwistDocumentation() (twist-scoped LLM documentation that omits connector-only modules) and TWIST_EXEMPLARS (complete example twists, compiled and type-checked on every build) for generation prompts.
53 changes: 52 additions & 1 deletion twister/prebuild.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

Expand DownExpand Up@@ -189,6 +196,50 @@ export default ${JSON.stringify(twistsTemplateContent)};
);
}

// Generate twist-exemplars.ts from src/exemplars/*.ts — complete example
// twists embedded for LLM generation prompts. Each file must start with a
// /* SPEC: ... */ block comment (the user-style specification it implements).
const exemplarsDir = join(srcDir, "exemplars");
if (!existsSync(exemplarsDir)) {
throw new Error("prebuild: src/exemplars/ is missing");
}
const exemplarFiles = readdirSync(exemplarsDir)
.filter((f) => f.endsWith(".ts"))
.sort();
if (exemplarFiles.length === 0) {
throw new Error("prebuild: src/exemplars/ contains no exemplars");
}
const exemplarSections = exemplarFiles.map((file) => {
const raw = readFileSync(join(exemplarsDir, file), "utf-8");
const specMatch = raw.match(/^\/\*\s*SPEC:\s*\n([\s\S]*?)\*\/\s*\n/);
if (!specMatch) {
throw new Error(`prebuild: ${file} is missing its leading /* SPEC: */ comment`);
}
const spec = specMatch[1].trim();
const implementation = raw.slice(specMatch[0].length).trim();
const title = file
.replace(/\.ts$/, "")
.split("-")
// Word-capitalize each hyphen segment, except known acronyms (e.g. "ai"
// -> "AI" for ai-responder.ts) which are upper-cased outright.
.map((w) =>
w.toLowerCase() === "ai" ? "AI" : w[0].toUpperCase() + w.slice(1)
)
.join(" ");
return `## Example: ${title}\n\n### Specification\n\n${spec}\n\n### Implementation\n\n\`\`\`typescript\n${implementation}\n\`\`\``;
});
const exemplarsContent = `/**
* Generated example twists for LLM generation prompts.
*
* This file is auto-generated during build. Do not edit manually.
* Generated from: src/exemplars/*.ts
*/

export default ${JSON.stringify(exemplarSections.join("\n\n"))};
`;
writeFileSync(join(llmDocsDir, "twist-exemplars.ts"), exemplarsContent, "utf-8");
console.log(`✓ Generated twist-exemplars.ts from ${exemplarFiles.length} exemplars`);

console.log(
`✓ Generated ${typeFiles.length} LLM documentation files in src/llm-docs/`
);
39 changes: 39 additions & 0 deletions twister/src/creator-docs.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import llmDocs from "./llm-docs/index.js";
import twistExemplars from "./llm-docs/twist-exemplars.js";

/**
* Gets complete Twist Creator type definitions with import paths for LLM context.
Expand DownExpand Up@@ -27,3 +28,41 @@ export function getBuilderDocumentation(): string {

return documentation;
}

// Modules excluded from TWIST generation docs: twists must never extend
// Connector, and the mail-protocol tools are niche enough to dilute the
// prompt more than they help.
const TWIST_DOC_EXCLUSIONS = new Set([
"@plotday/twister/connector",
"@plotday/twister/tools/imap",
"@plotday/twister/tools/smtp",
]);

/**
* Twist-scoped variant of getBuilderDocumentation(): the same formatted
* type definitions, minus modules that are irrelevant (or misleading) when
* generating a twist.
*/
export function getTwistDocumentation(): string {
let documentation = "# Plot Twist Creator Type Definitions\n\n";
documentation +=
"Complete type definitions with JSDoc documentation for all Plot Twist Creator types.\n";
documentation +=
"These are the source files - use the import paths shown to import types in your twist code.\n\n";
for (const [importPath, content] of Object.entries(llmDocs)) {
if (TWIST_DOC_EXCLUSIONS.has(importPath)) continue;
documentation += `## ${importPath}\n\n`;
documentation += "```typescript\n";
documentation += `// Import from: ${importPath}\n\n`;
documentation += content;
documentation += "\n```\n\n";
}
return documentation;
}

/**
* Complete example twists (specification + implementation pairs) for LLM
* generation prompts. The examples are real compiled sources in
* src/exemplars/, so they are type-checked on every build.
*/
export const TWIST_EXEMPLARS: string = twistExemplars;
60 changes: 60 additions & 0 deletions twister/src/exemplars/ai-responder.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
/* SPEC:
When someone writes a journal note in another language, reply in the same
thread with a short plain-English summary of what they wrote, so they can
check their own understanding. Don't react to notes written by automations.
*/
import { ActorType, Twist, type Note, type ToolBuilder } from "@plotday/twister";
import { AI } from "@plotday/twister/tools/ai";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class LanguageJournal extends Twist<LanguageJournal> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
// onNoteCreated only fires for notes on threads this twist created,
// so Create access is required even though nothing else reads or
// updates other threads.
thread: { access: ThreadAccess.Create },
}),
ai: build(AI),
};
}

async activate() {
// Seed the standing thread the user writes journal entries into.
await this.tools.plot.createThread({
title: "Language journal",
notes: [
{
content:
"Write your journal entries here in any language — I'll reply with a plain-English summary.",
},
],
});
}

// Fires for every new note on a thread this twist created. Guard against
// notes from twists/automations so we never loop on our own replies.
async onNoteCreated(note: Note): Promise<void> {
if (note.author.type === ActorType.Twist) {
return;
}
if (!note.content || note.content.trim().length === 0) {
return;
}

const response = await this.tools.ai.prompt({
model: { speed: "fast", cost: "low" },
prompt: `Summarize this journal entry in one or two plain-English sentences:\n\n${note.content}`,
});
if (!response.text) {
return;
}

// Reply in the same thread the note belongs to.
await this.tools.plot.createNote({
thread: { id: note.thread.id },
content: `English summary: ${response.text}`,
});
}
}
84 changes: 84 additions & 0 deletions twister/src/exemplars/authenticated-sync.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
/* SPEC:
Connect to my bookmarking service account. Once connected, import my starred
bookmarks as threads (title + link note), and check for new ones every hour.
Imported bookmarks must not duplicate on re-sync.
*/
import { ActionType, Twist, type ToolBuilder, type Uuid } from "@plotday/twister";
import { Options } from "@plotday/twister/options";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class BookmarkSync extends Twist<BookmarkSync> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
// Plain twists can't hold OAuth tokens — that machinery
// (provider/scopes/channels) belongs to Connectors. A secure Options
// field is the twist-safe way to let the user "connect" an account
// for a twist to call directly.
options: build(Options, {
apiKey: {
type: "text",
label: "Bookmarking service API key",
default: "",
secure: true,
},
}),
network: build(Network, {
urls: ["https://api.bookmarks.example/*"],
}),
};
}

async activate() {
// Re-runs hourly under a stable key; survives restarts and upgrades.
await this.scheduleRecurring(
"hourly-sync",
await this.callback(this.sync),
{ intervalMs: 60 * 60 * 1000 }
);
await this.sync();
}

async sync(): Promise<void> {
const { apiKey } = this.tools.options;
if (!apiKey) {
return; // Not connected yet; nothing to sync.
}

const response = await fetch("https://api.bookmarks.example/v1/starred", {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
return;
}
const { bookmarks } = (await response.json()) as {
bookmarks: Array<{ id: string; title: string; url: string }>;
};

// Threads a twist creates have no external Link (that's a Connector
// concept), so dedup is tracked manually: store the bookmark id -> thread
// id mapping and skip any bookmark that's already been imported.
for (const bookmark of bookmarks) {
const mappingKey = `bookmark:${bookmark.id}`;
if (await this.get<Uuid>(mappingKey)) {
continue;
}

const threadId = await this.tools.plot.createThread({
title: bookmark.title,
notes: [
{
content: bookmark.url,
actions: [
{ type: ActionType.external, title: "Open bookmark", url: bookmark.url },
],
},
],
});
await this.set(mappingKey, threadId);
}
}
}
61 changes: 61 additions & 0 deletions twister/src/exemplars/scheduled-digest.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
/* SPEC:
Every morning, post a thread with today's weather forecast for my city so I
can plan the day. One thread per day, titled with the date.
*/
import { Twist, type ToolBuilder } from "@plotday/twister";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class WeatherDigest extends Twist<WeatherDigest> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
network: build(Network, {
urls: ["https://api.open-meteo.com/*"],
}),
};
}

async activate() {
await this.scheduleRecurring(
"morning-digest",
await this.callback(this.postDigest),
{ intervalMs: 24 * 60 * 60 * 1000 }
);
}

async postDigest(): Promise<void> {
// Scheduled callbacks can fire more than once (at-least-once delivery),
// so guard "one thread per day" with a stored marker keyed on the date.
const today = new Date().toISOString().slice(0, 10);
const postedKey = `posted:${today}`;
if (await this.get<boolean>(postedKey)) {
return;
}

const response = await fetch(
"https://api.open-meteo.com/v1/forecast?latitude=43.65&longitude=-79.38&daily=temperature_2m_max,precipitation_probability_mean&timezone=auto&forecast_days=1"
);
if (!response.ok) {
return;
}
const data = (await response.json()) as {
daily: {
temperature_2m_max: number[];
precipitation_probability_mean: number[];
};
};

await this.tools.plot.createThread({
title: `Weather for ${today}`,
notes: [
{
content: `High of ${data.daily.temperature_2m_max[0]}°C, ${data.daily.precipitation_probability_mean[0]}% chance of rain.`,
},
],
});
await this.set(postedKey, true);
}
}
13 changes: 12 additions & 1 deletion twister/tsconfig.build.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,18 @@
"declarationMap": true,
"sourceMap": true,
"noEmit": false,
"composite": false
"composite": false,
// src/exemplars/*.ts import "@plotday/twister" by package name (so the
// embedded LLM-facing examples show real import paths, not relative
// ones). Resolving that self-reference requires the same
// moduleResolution + custom condition tsconfig.base.json already grants
// downstream twist/connector projects for the reverse case (resolving
// @plotday/twister straight to this package's src/*). Scoped to this
// build-only config (not tsconfig.json / tsconfig.cli.json) since
// "bundler" resolution is incompatible with tsconfig.cli.json's
// CommonJS output (TS5095).
"moduleResolution": "bundler",
"customConditions": ["@plotday/connector"]
},
"include": ["src/**/*.ts"],
"exclude": [
Expand Down
3 changes: 2 additions & 1 deletion twister/typedoc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,12 @@
"src/utils/hash.ts"
],
"out": "dist/docs",
"tsconfig": "./tsconfig.json",
"tsconfig": "./tsconfig.build.json",
"exclude": [
"**/*+(.spec|.test).ts",
"**/llm-docs/**",
"**/cli/**",
"**/exemplars/**",
"src/twist-guide.ts",
"src/creator-docs.ts",
"prebuild.ts",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(twister): twist-scoped LLM docs and compiled example twists by KrisBraun · Pull Request #277 · plotday/plot · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twist-prompt-docs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: getTwistDocumentation() (twist-scoped LLM documentation that omits connector-only modules) and TWIST_EXEMPLARS (complete example twists, compiled and type-checked on every build) for generation prompts.
53 changes: 52 additions & 1 deletion twister/prebuild.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

Expand DownExpand Up@@ -189,6 +196,50 @@ export default ${JSON.stringify(twistsTemplateContent)};
);
}

// Generate twist-exemplars.ts from src/exemplars/*.ts — complete example
// twists embedded for LLM generation prompts. Each file must start with a
// /* SPEC: ... */ block comment (the user-style specification it implements).
const exemplarsDir = join(srcDir, "exemplars");
if (!existsSync(exemplarsDir)) {
throw new Error("prebuild: src/exemplars/ is missing");
}
const exemplarFiles = readdirSync(exemplarsDir)
.filter((f) => f.endsWith(".ts"))
.sort();
if (exemplarFiles.length === 0) {
throw new Error("prebuild: src/exemplars/ contains no exemplars");
}
const exemplarSections = exemplarFiles.map((file) => {
const raw = readFileSync(join(exemplarsDir, file), "utf-8");
const specMatch = raw.match(/^\/\*\s*SPEC:\s*\n([\s\S]*?)\*\/\s*\n/);
if (!specMatch) {
throw new Error(`prebuild: ${file} is missing its leading /* SPEC: */ comment`);
}
const spec = specMatch[1].trim();
const implementation = raw.slice(specMatch[0].length).trim();
const title = file
.replace(/\.ts$/, "")
.split("-")
// Word-capitalize each hyphen segment, except known acronyms (e.g. "ai"
// -> "AI" for ai-responder.ts) which are upper-cased outright.
.map((w) =>
w.toLowerCase() === "ai" ? "AI" : w[0].toUpperCase() + w.slice(1)
)
.join(" ");
return `## Example: ${title}\n\n### Specification\n\n${spec}\n\n### Implementation\n\n\`\`\`typescript\n${implementation}\n\`\`\``;
});
const exemplarsContent = `/**
* Generated example twists for LLM generation prompts.
*
* This file is auto-generated during build. Do not edit manually.
* Generated from: src/exemplars/*.ts
*/

export default ${JSON.stringify(exemplarSections.join("\n\n"))};
`;
writeFileSync(join(llmDocsDir, "twist-exemplars.ts"), exemplarsContent, "utf-8");
console.log(`✓ Generated twist-exemplars.ts from ${exemplarFiles.length} exemplars`);

console.log(
`✓ Generated ${typeFiles.length} LLM documentation files in src/llm-docs/`
);
39 changes: 39 additions & 0 deletions twister/src/creator-docs.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import llmDocs from "./llm-docs/index.js";
import twistExemplars from "./llm-docs/twist-exemplars.js";

/**
* Gets complete Twist Creator type definitions with import paths for LLM context.
Expand DownExpand Up@@ -27,3 +28,41 @@ export function getBuilderDocumentation(): string {

return documentation;
}

// Modules excluded from TWIST generation docs: twists must never extend
// Connector, and the mail-protocol tools are niche enough to dilute the
// prompt more than they help.
const TWIST_DOC_EXCLUSIONS = new Set([
"@plotday/twister/connector",
"@plotday/twister/tools/imap",
"@plotday/twister/tools/smtp",
]);

/**
* Twist-scoped variant of getBuilderDocumentation(): the same formatted
* type definitions, minus modules that are irrelevant (or misleading) when
* generating a twist.
*/
export function getTwistDocumentation(): string {
let documentation = "# Plot Twist Creator Type Definitions\n\n";
documentation +=
"Complete type definitions with JSDoc documentation for all Plot Twist Creator types.\n";
documentation +=
"These are the source files - use the import paths shown to import types in your twist code.\n\n";
for (const [importPath, content] of Object.entries(llmDocs)) {
if (TWIST_DOC_EXCLUSIONS.has(importPath)) continue;
documentation += `## ${importPath}\n\n`;
documentation += "```typescript\n";
documentation += `// Import from: ${importPath}\n\n`;
documentation += content;
documentation += "\n```\n\n";
}
return documentation;
}

/**
* Complete example twists (specification + implementation pairs) for LLM
* generation prompts. The examples are real compiled sources in
* src/exemplars/, so they are type-checked on every build.
*/
export const TWIST_EXEMPLARS: string = twistExemplars;
60 changes: 60 additions & 0 deletions twister/src/exemplars/ai-responder.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
/* SPEC:
When someone writes a journal note in another language, reply in the same
thread with a short plain-English summary of what they wrote, so they can
check their own understanding. Don't react to notes written by automations.
*/
import { ActorType, Twist, type Note, type ToolBuilder } from "@plotday/twister";
import { AI } from "@plotday/twister/tools/ai";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class LanguageJournal extends Twist<LanguageJournal> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
// onNoteCreated only fires for notes on threads this twist created,
// so Create access is required even though nothing else reads or
// updates other threads.
thread: { access: ThreadAccess.Create },
}),
ai: build(AI),
};
}

async activate() {
// Seed the standing thread the user writes journal entries into.
await this.tools.plot.createThread({
title: "Language journal",
notes: [
{
content:
"Write your journal entries here in any language — I'll reply with a plain-English summary.",
},
],
});
}

// Fires for every new note on a thread this twist created. Guard against
// notes from twists/automations so we never loop on our own replies.
async onNoteCreated(note: Note): Promise<void> {
if (note.author.type === ActorType.Twist) {
return;
}
if (!note.content || note.content.trim().length === 0) {
return;
}

const response = await this.tools.ai.prompt({
model: { speed: "fast", cost: "low" },
prompt: `Summarize this journal entry in one or two plain-English sentences:\n\n${note.content}`,
});
if (!response.text) {
return;
}

// Reply in the same thread the note belongs to.
await this.tools.plot.createNote({
thread: { id: note.thread.id },
content: `English summary: ${response.text}`,
});
}
}
84 changes: 84 additions & 0 deletions twister/src/exemplars/authenticated-sync.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
/* SPEC:
Connect to my bookmarking service account. Once connected, import my starred
bookmarks as threads (title + link note), and check for new ones every hour.
Imported bookmarks must not duplicate on re-sync.
*/
import { ActionType, Twist, type ToolBuilder, type Uuid } from "@plotday/twister";
import { Options } from "@plotday/twister/options";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class BookmarkSync extends Twist<BookmarkSync> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
// Plain twists can't hold OAuth tokens — that machinery
// (provider/scopes/channels) belongs to Connectors. A secure Options
// field is the twist-safe way to let the user "connect" an account
// for a twist to call directly.
options: build(Options, {
apiKey: {
type: "text",
label: "Bookmarking service API key",
default: "",
secure: true,
},
}),
network: build(Network, {
urls: ["https://api.bookmarks.example/*"],
}),
};
}

async activate() {
// Re-runs hourly under a stable key; survives restarts and upgrades.
await this.scheduleRecurring(
"hourly-sync",
await this.callback(this.sync),
{ intervalMs: 60 * 60 * 1000 }
);
await this.sync();
}

async sync(): Promise<void> {
const { apiKey } = this.tools.options;
if (!apiKey) {
return; // Not connected yet; nothing to sync.
}

const response = await fetch("https://api.bookmarks.example/v1/starred", {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
return;
}
const { bookmarks } = (await response.json()) as {
bookmarks: Array<{ id: string; title: string; url: string }>;
};

// Threads a twist creates have no external Link (that's a Connector
// concept), so dedup is tracked manually: store the bookmark id -> thread
// id mapping and skip any bookmark that's already been imported.
for (const bookmark of bookmarks) {
const mappingKey = `bookmark:${bookmark.id}`;
if (await this.get<Uuid>(mappingKey)) {
continue;
}

const threadId = await this.tools.plot.createThread({
title: bookmark.title,
notes: [
{
content: bookmark.url,
actions: [
{ type: ActionType.external, title: "Open bookmark", url: bookmark.url },
],
},
],
});
await this.set(mappingKey, threadId);
}
}
}
61 changes: 61 additions & 0 deletions twister/src/exemplars/scheduled-digest.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
/* SPEC:
Every morning, post a thread with today's weather forecast for my city so I
can plan the day. One thread per day, titled with the date.
*/
import { Twist, type ToolBuilder } from "@plotday/twister";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class WeatherDigest extends Twist<WeatherDigest> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
network: build(Network, {
urls: ["https://api.open-meteo.com/*"],
}),
};
}

async activate() {
await this.scheduleRecurring(
"morning-digest",
await this.callback(this.postDigest),
{ intervalMs: 24 * 60 * 60 * 1000 }
);
}

async postDigest(): Promise<void> {
// Scheduled callbacks can fire more than once (at-least-once delivery),
// so guard "one thread per day" with a stored marker keyed on the date.
const today = new Date().toISOString().slice(0, 10);
const postedKey = `posted:${today}`;
if (await this.get<boolean>(postedKey)) {
return;
}

const response = await fetch(
"https://api.open-meteo.com/v1/forecast?latitude=43.65&longitude=-79.38&daily=temperature_2m_max,precipitation_probability_mean&timezone=auto&forecast_days=1"
);
if (!response.ok) {
return;
}
const data = (await response.json()) as {
daily: {
temperature_2m_max: number[];
precipitation_probability_mean: number[];
};
};

await this.tools.plot.createThread({
title: `Weather for ${today}`,
notes: [
{
content: `High of ${data.daily.temperature_2m_max[0]}°C, ${data.daily.precipitation_probability_mean[0]}% chance of rain.`,
},
],
});
await this.set(postedKey, true);
}
}
13 changes: 12 additions & 1 deletion twister/tsconfig.build.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,18 @@
"declarationMap": true,
"sourceMap": true,
"noEmit": false,
"composite": false
"composite": false,
// src/exemplars/*.ts import "@plotday/twister" by package name (so the
// embedded LLM-facing examples show real import paths, not relative
// ones). Resolving that self-reference requires the same
// moduleResolution + custom condition tsconfig.base.json already grants
// downstream twist/connector projects for the reverse case (resolving
// @plotday/twister straight to this package's src/*). Scoped to this
// build-only config (not tsconfig.json / tsconfig.cli.json) since
// "bundler" resolution is incompatible with tsconfig.cli.json's
// CommonJS output (TS5095).
"moduleResolution": "bundler",
"customConditions": ["@plotday/connector"]
},
"include": ["src/**/*.ts"],
"exclude": [
Expand Down
3 changes: 2 additions & 1 deletion twister/typedoc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,12 @@
"src/utils/hash.ts"
],
"out": "dist/docs",
"tsconfig": "./tsconfig.json",
"tsconfig": "./tsconfig.build.json",
"exclude": [
"**/*+(.spec|.test).ts",
"**/llm-docs/**",
"**/cli/**",
"**/exemplars/**",
"src/twist-guide.ts",
"src/creator-docs.ts",
"prebuild.ts",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(twister): twist-scoped LLM docs and compiled example twists by KrisBraun · Pull Request #277 · plotday/plot · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twist-prompt-docs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: getTwistDocumentation() (twist-scoped LLM documentation that omits connector-only modules) and TWIST_EXEMPLARS (complete example twists, compiled and type-checked on every build) for generation prompts.
53 changes: 52 additions & 1 deletion twister/prebuild.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

Expand DownExpand Up@@ -189,6 +196,50 @@ export default ${JSON.stringify(twistsTemplateContent)};
);
}

// Generate twist-exemplars.ts from src/exemplars/*.ts — complete example
// twists embedded for LLM generation prompts. Each file must start with a
// /* SPEC: ... */ block comment (the user-style specification it implements).
const exemplarsDir = join(srcDir, "exemplars");
if (!existsSync(exemplarsDir)) {
throw new Error("prebuild: src/exemplars/ is missing");
}
const exemplarFiles = readdirSync(exemplarsDir)
.filter((f) => f.endsWith(".ts"))
.sort();
if (exemplarFiles.length === 0) {
throw new Error("prebuild: src/exemplars/ contains no exemplars");
}
const exemplarSections = exemplarFiles.map((file) => {
const raw = readFileSync(join(exemplarsDir, file), "utf-8");
const specMatch = raw.match(/^\/\*\s*SPEC:\s*\n([\s\S]*?)\*\/\s*\n/);
if (!specMatch) {
throw new Error(`prebuild: ${file} is missing its leading /* SPEC: */ comment`);
}
const spec = specMatch[1].trim();
const implementation = raw.slice(specMatch[0].length).trim();
const title = file
.replace(/\.ts$/, "")
.split("-")
// Word-capitalize each hyphen segment, except known acronyms (e.g. "ai"
// -> "AI" for ai-responder.ts) which are upper-cased outright.
.map((w) =>
w.toLowerCase() === "ai" ? "AI" : w[0].toUpperCase() + w.slice(1)
)
.join(" ");
return `## Example: ${title}\n\n### Specification\n\n${spec}\n\n### Implementation\n\n\`\`\`typescript\n${implementation}\n\`\`\``;
});
const exemplarsContent = `/**
* Generated example twists for LLM generation prompts.
*
* This file is auto-generated during build. Do not edit manually.
* Generated from: src/exemplars/*.ts
*/

export default ${JSON.stringify(exemplarSections.join("\n\n"))};
`;
writeFileSync(join(llmDocsDir, "twist-exemplars.ts"), exemplarsContent, "utf-8");
console.log(`✓ Generated twist-exemplars.ts from ${exemplarFiles.length} exemplars`);

console.log(
`✓ Generated ${typeFiles.length} LLM documentation files in src/llm-docs/`
);
39 changes: 39 additions & 0 deletions twister/src/creator-docs.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import llmDocs from "./llm-docs/index.js";
import twistExemplars from "./llm-docs/twist-exemplars.js";

/**
* Gets complete Twist Creator type definitions with import paths for LLM context.
Expand DownExpand Up@@ -27,3 +28,41 @@ export function getBuilderDocumentation(): string {

return documentation;
}

// Modules excluded from TWIST generation docs: twists must never extend
// Connector, and the mail-protocol tools are niche enough to dilute the
// prompt more than they help.
const TWIST_DOC_EXCLUSIONS = new Set([
"@plotday/twister/connector",
"@plotday/twister/tools/imap",
"@plotday/twister/tools/smtp",
]);

/**
* Twist-scoped variant of getBuilderDocumentation(): the same formatted
* type definitions, minus modules that are irrelevant (or misleading) when
* generating a twist.
*/
export function getTwistDocumentation(): string {
let documentation = "# Plot Twist Creator Type Definitions\n\n";
documentation +=
"Complete type definitions with JSDoc documentation for all Plot Twist Creator types.\n";
documentation +=
"These are the source files - use the import paths shown to import types in your twist code.\n\n";
for (const [importPath, content] of Object.entries(llmDocs)) {
if (TWIST_DOC_EXCLUSIONS.has(importPath)) continue;
documentation += `## ${importPath}\n\n`;
documentation += "```typescript\n";
documentation += `// Import from: ${importPath}\n\n`;
documentation += content;
documentation += "\n```\n\n";
}
return documentation;
}

/**
* Complete example twists (specification + implementation pairs) for LLM
* generation prompts. The examples are real compiled sources in
* src/exemplars/, so they are type-checked on every build.
*/
export const TWIST_EXEMPLARS: string = twistExemplars;
60 changes: 60 additions & 0 deletions twister/src/exemplars/ai-responder.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
/* SPEC:
When someone writes a journal note in another language, reply in the same
thread with a short plain-English summary of what they wrote, so they can
check their own understanding. Don't react to notes written by automations.
*/
import { ActorType, Twist, type Note, type ToolBuilder } from "@plotday/twister";
import { AI } from "@plotday/twister/tools/ai";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class LanguageJournal extends Twist<LanguageJournal> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
// onNoteCreated only fires for notes on threads this twist created,
// so Create access is required even though nothing else reads or
// updates other threads.
thread: { access: ThreadAccess.Create },
}),
ai: build(AI),
};
}

async activate() {
// Seed the standing thread the user writes journal entries into.
await this.tools.plot.createThread({
title: "Language journal",
notes: [
{
content:
"Write your journal entries here in any language — I'll reply with a plain-English summary.",
},
],
});
}

// Fires for every new note on a thread this twist created. Guard against
// notes from twists/automations so we never loop on our own replies.
async onNoteCreated(note: Note): Promise<void> {
if (note.author.type === ActorType.Twist) {
return;
}
if (!note.content || note.content.trim().length === 0) {
return;
}

const response = await this.tools.ai.prompt({
model: { speed: "fast", cost: "low" },
prompt: `Summarize this journal entry in one or two plain-English sentences:\n\n${note.content}`,
});
if (!response.text) {
return;
}

// Reply in the same thread the note belongs to.
await this.tools.plot.createNote({
thread: { id: note.thread.id },
content: `English summary: ${response.text}`,
});
}
}
84 changes: 84 additions & 0 deletions twister/src/exemplars/authenticated-sync.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
/* SPEC:
Connect to my bookmarking service account. Once connected, import my starred
bookmarks as threads (title + link note), and check for new ones every hour.
Imported bookmarks must not duplicate on re-sync.
*/
import { ActionType, Twist, type ToolBuilder, type Uuid } from "@plotday/twister";
import { Options } from "@plotday/twister/options";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class BookmarkSync extends Twist<BookmarkSync> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
// Plain twists can't hold OAuth tokens — that machinery
// (provider/scopes/channels) belongs to Connectors. A secure Options
// field is the twist-safe way to let the user "connect" an account
// for a twist to call directly.
options: build(Options, {
apiKey: {
type: "text",
label: "Bookmarking service API key",
default: "",
secure: true,
},
}),
network: build(Network, {
urls: ["https://api.bookmarks.example/*"],
}),
};
}

async activate() {
// Re-runs hourly under a stable key; survives restarts and upgrades.
await this.scheduleRecurring(
"hourly-sync",
await this.callback(this.sync),
{ intervalMs: 60 * 60 * 1000 }
);
await this.sync();
}

async sync(): Promise<void> {
const { apiKey } = this.tools.options;
if (!apiKey) {
return; // Not connected yet; nothing to sync.
}

const response = await fetch("https://api.bookmarks.example/v1/starred", {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
return;
}
const { bookmarks } = (await response.json()) as {
bookmarks: Array<{ id: string; title: string; url: string }>;
};

// Threads a twist creates have no external Link (that's a Connector
// concept), so dedup is tracked manually: store the bookmark id -> thread
// id mapping and skip any bookmark that's already been imported.
for (const bookmark of bookmarks) {
const mappingKey = `bookmark:${bookmark.id}`;
if (await this.get<Uuid>(mappingKey)) {
continue;
}

const threadId = await this.tools.plot.createThread({
title: bookmark.title,
notes: [
{
content: bookmark.url,
actions: [
{ type: ActionType.external, title: "Open bookmark", url: bookmark.url },
],
},
],
});
await this.set(mappingKey, threadId);
}
}
}
61 changes: 61 additions & 0 deletions twister/src/exemplars/scheduled-digest.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
/* SPEC:
Every morning, post a thread with today's weather forecast for my city so I
can plan the day. One thread per day, titled with the date.
*/
import { Twist, type ToolBuilder } from "@plotday/twister";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class WeatherDigest extends Twist<WeatherDigest> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
network: build(Network, {
urls: ["https://api.open-meteo.com/*"],
}),
};
}

async activate() {
await this.scheduleRecurring(
"morning-digest",
await this.callback(this.postDigest),
{ intervalMs: 24 * 60 * 60 * 1000 }
);
}

async postDigest(): Promise<void> {
// Scheduled callbacks can fire more than once (at-least-once delivery),
// so guard "one thread per day" with a stored marker keyed on the date.
const today = new Date().toISOString().slice(0, 10);
const postedKey = `posted:${today}`;
if (await this.get<boolean>(postedKey)) {
return;
}

const response = await fetch(
"https://api.open-meteo.com/v1/forecast?latitude=43.65&longitude=-79.38&daily=temperature_2m_max,precipitation_probability_mean&timezone=auto&forecast_days=1"
);
if (!response.ok) {
return;
}
const data = (await response.json()) as {
daily: {
temperature_2m_max: number[];
precipitation_probability_mean: number[];
};
};

await this.tools.plot.createThread({
title: `Weather for ${today}`,
notes: [
{
content: `High of ${data.daily.temperature_2m_max[0]}°C, ${data.daily.precipitation_probability_mean[0]}% chance of rain.`,
},
],
});
await this.set(postedKey, true);
}
}
13 changes: 12 additions & 1 deletion twister/tsconfig.build.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,18 @@
"declarationMap": true,
"sourceMap": true,
"noEmit": false,
"composite": false
"composite": false,
// src/exemplars/*.ts import "@plotday/twister" by package name (so the
// embedded LLM-facing examples show real import paths, not relative
// ones). Resolving that self-reference requires the same
// moduleResolution + custom condition tsconfig.base.json already grants
// downstream twist/connector projects for the reverse case (resolving
// @plotday/twister straight to this package's src/*). Scoped to this
// build-only config (not tsconfig.json / tsconfig.cli.json) since
// "bundler" resolution is incompatible with tsconfig.cli.json's
// CommonJS output (TS5095).
"moduleResolution": "bundler",
"customConditions": ["@plotday/connector"]
},
"include": ["src/**/*.ts"],
"exclude": [
Expand Down
3 changes: 2 additions & 1 deletion twister/typedoc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,12 @@
"src/utils/hash.ts"
],
"out": "dist/docs",
"tsconfig": "./tsconfig.json",
"tsconfig": "./tsconfig.build.json",
"exclude": [
"**/*+(.spec|.test).ts",
"**/llm-docs/**",
"**/cli/**",
"**/exemplars/**",
"src/twist-guide.ts",
"src/creator-docs.ts",
"prebuild.ts",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(twister): twist-scoped LLM docs and compiled example twists by KrisBraun · Pull Request #277 · plotday/plot · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twist-prompt-docs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: getTwistDocumentation() (twist-scoped LLM documentation that omits connector-only modules) and TWIST_EXEMPLARS (complete example twists, compiled and type-checked on every build) for generation prompts.
53 changes: 52 additions & 1 deletion twister/prebuild.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

Expand DownExpand Up@@ -189,6 +196,50 @@ export default ${JSON.stringify(twistsTemplateContent)};
);
}

// Generate twist-exemplars.ts from src/exemplars/*.ts — complete example
// twists embedded for LLM generation prompts. Each file must start with a
// /* SPEC: ... */ block comment (the user-style specification it implements).
const exemplarsDir = join(srcDir, "exemplars");
if (!existsSync(exemplarsDir)) {
throw new Error("prebuild: src/exemplars/ is missing");
}
const exemplarFiles = readdirSync(exemplarsDir)
.filter((f) => f.endsWith(".ts"))
.sort();
if (exemplarFiles.length === 0) {
throw new Error("prebuild: src/exemplars/ contains no exemplars");
}
const exemplarSections = exemplarFiles.map((file) => {
const raw = readFileSync(join(exemplarsDir, file), "utf-8");
const specMatch = raw.match(/^\/\*\s*SPEC:\s*\n([\s\S]*?)\*\/\s*\n/);
if (!specMatch) {
throw new Error(`prebuild: ${file} is missing its leading /* SPEC: */ comment`);
}
const spec = specMatch[1].trim();
const implementation = raw.slice(specMatch[0].length).trim();
const title = file
.replace(/\.ts$/, "")
.split("-")
// Word-capitalize each hyphen segment, except known acronyms (e.g. "ai"
// -> "AI" for ai-responder.ts) which are upper-cased outright.
.map((w) =>
w.toLowerCase() === "ai" ? "AI" : w[0].toUpperCase() + w.slice(1)
)
.join(" ");
return `## Example: ${title}\n\n### Specification\n\n${spec}\n\n### Implementation\n\n\`\`\`typescript\n${implementation}\n\`\`\``;
});
const exemplarsContent = `/**
* Generated example twists for LLM generation prompts.
*
* This file is auto-generated during build. Do not edit manually.
* Generated from: src/exemplars/*.ts
*/

export default ${JSON.stringify(exemplarSections.join("\n\n"))};
`;
writeFileSync(join(llmDocsDir, "twist-exemplars.ts"), exemplarsContent, "utf-8");
console.log(`✓ Generated twist-exemplars.ts from ${exemplarFiles.length} exemplars`);

console.log(
`✓ Generated ${typeFiles.length} LLM documentation files in src/llm-docs/`
);
39 changes: 39 additions & 0 deletions twister/src/creator-docs.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import llmDocs from "./llm-docs/index.js";
import twistExemplars from "./llm-docs/twist-exemplars.js";

/**
* Gets complete Twist Creator type definitions with import paths for LLM context.
Expand DownExpand Up@@ -27,3 +28,41 @@ export function getBuilderDocumentation(): string {

return documentation;
}

// Modules excluded from TWIST generation docs: twists must never extend
// Connector, and the mail-protocol tools are niche enough to dilute the
// prompt more than they help.
const TWIST_DOC_EXCLUSIONS = new Set([
"@plotday/twister/connector",
"@plotday/twister/tools/imap",
"@plotday/twister/tools/smtp",
]);

/**
* Twist-scoped variant of getBuilderDocumentation(): the same formatted
* type definitions, minus modules that are irrelevant (or misleading) when
* generating a twist.
*/
export function getTwistDocumentation(): string {
let documentation = "# Plot Twist Creator Type Definitions\n\n";
documentation +=
"Complete type definitions with JSDoc documentation for all Plot Twist Creator types.\n";
documentation +=
"These are the source files - use the import paths shown to import types in your twist code.\n\n";
for (const [importPath, content] of Object.entries(llmDocs)) {
if (TWIST_DOC_EXCLUSIONS.has(importPath)) continue;
documentation += `## ${importPath}\n\n`;
documentation += "```typescript\n";
documentation += `// Import from: ${importPath}\n\n`;
documentation += content;
documentation += "\n```\n\n";
}
return documentation;
}

/**
* Complete example twists (specification + implementation pairs) for LLM
* generation prompts. The examples are real compiled sources in
* src/exemplars/, so they are type-checked on every build.
*/
export const TWIST_EXEMPLARS: string = twistExemplars;
60 changes: 60 additions & 0 deletions twister/src/exemplars/ai-responder.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
/* SPEC:
When someone writes a journal note in another language, reply in the same
thread with a short plain-English summary of what they wrote, so they can
check their own understanding. Don't react to notes written by automations.
*/
import { ActorType, Twist, type Note, type ToolBuilder } from "@plotday/twister";
import { AI } from "@plotday/twister/tools/ai";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class LanguageJournal extends Twist<LanguageJournal> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
// onNoteCreated only fires for notes on threads this twist created,
// so Create access is required even though nothing else reads or
// updates other threads.
thread: { access: ThreadAccess.Create },
}),
ai: build(AI),
};
}

async activate() {
// Seed the standing thread the user writes journal entries into.
await this.tools.plot.createThread({
title: "Language journal",
notes: [
{
content:
"Write your journal entries here in any language — I'll reply with a plain-English summary.",
},
],
});
}

// Fires for every new note on a thread this twist created. Guard against
// notes from twists/automations so we never loop on our own replies.
async onNoteCreated(note: Note): Promise<void> {
if (note.author.type === ActorType.Twist) {
return;
}
if (!note.content || note.content.trim().length === 0) {
return;
}

const response = await this.tools.ai.prompt({
model: { speed: "fast", cost: "low" },
prompt: `Summarize this journal entry in one or two plain-English sentences:\n\n${note.content}`,
});
if (!response.text) {
return;
}

// Reply in the same thread the note belongs to.
await this.tools.plot.createNote({
thread: { id: note.thread.id },
content: `English summary: ${response.text}`,
});
}
}
84 changes: 84 additions & 0 deletions twister/src/exemplars/authenticated-sync.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
/* SPEC:
Connect to my bookmarking service account. Once connected, import my starred
bookmarks as threads (title + link note), and check for new ones every hour.
Imported bookmarks must not duplicate on re-sync.
*/
import { ActionType, Twist, type ToolBuilder, type Uuid } from "@plotday/twister";
import { Options } from "@plotday/twister/options";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class BookmarkSync extends Twist<BookmarkSync> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
// Plain twists can't hold OAuth tokens — that machinery
// (provider/scopes/channels) belongs to Connectors. A secure Options
// field is the twist-safe way to let the user "connect" an account
// for a twist to call directly.
options: build(Options, {
apiKey: {
type: "text",
label: "Bookmarking service API key",
default: "",
secure: true,
},
}),
network: build(Network, {
urls: ["https://api.bookmarks.example/*"],
}),
};
}

async activate() {
// Re-runs hourly under a stable key; survives restarts and upgrades.
await this.scheduleRecurring(
"hourly-sync",
await this.callback(this.sync),
{ intervalMs: 60 * 60 * 1000 }
);
await this.sync();
}

async sync(): Promise<void> {
const { apiKey } = this.tools.options;
if (!apiKey) {
return; // Not connected yet; nothing to sync.
}

const response = await fetch("https://api.bookmarks.example/v1/starred", {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
return;
}
const { bookmarks } = (await response.json()) as {
bookmarks: Array<{ id: string; title: string; url: string }>;
};

// Threads a twist creates have no external Link (that's a Connector
// concept), so dedup is tracked manually: store the bookmark id -> thread
// id mapping and skip any bookmark that's already been imported.
for (const bookmark of bookmarks) {
const mappingKey = `bookmark:${bookmark.id}`;
if (await this.get<Uuid>(mappingKey)) {
continue;
}

const threadId = await this.tools.plot.createThread({
title: bookmark.title,
notes: [
{
content: bookmark.url,
actions: [
{ type: ActionType.external, title: "Open bookmark", url: bookmark.url },
],
},
],
});
await this.set(mappingKey, threadId);
}
}
}
61 changes: 61 additions & 0 deletions twister/src/exemplars/scheduled-digest.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
/* SPEC:
Every morning, post a thread with today's weather forecast for my city so I
can plan the day. One thread per day, titled with the date.
*/
import { Twist, type ToolBuilder } from "@plotday/twister";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class WeatherDigest extends Twist<WeatherDigest> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
network: build(Network, {
urls: ["https://api.open-meteo.com/*"],
}),
};
}

async activate() {
await this.scheduleRecurring(
"morning-digest",
await this.callback(this.postDigest),
{ intervalMs: 24 * 60 * 60 * 1000 }
);
}

async postDigest(): Promise<void> {
// Scheduled callbacks can fire more than once (at-least-once delivery),
// so guard "one thread per day" with a stored marker keyed on the date.
const today = new Date().toISOString().slice(0, 10);
const postedKey = `posted:${today}`;
if (await this.get<boolean>(postedKey)) {
return;
}

const response = await fetch(
"https://api.open-meteo.com/v1/forecast?latitude=43.65&longitude=-79.38&daily=temperature_2m_max,precipitation_probability_mean&timezone=auto&forecast_days=1"
);
if (!response.ok) {
return;
}
const data = (await response.json()) as {
daily: {
temperature_2m_max: number[];
precipitation_probability_mean: number[];
};
};

await this.tools.plot.createThread({
title: `Weather for ${today}`,
notes: [
{
content: `High of ${data.daily.temperature_2m_max[0]}°C, ${data.daily.precipitation_probability_mean[0]}% chance of rain.`,
},
],
});
await this.set(postedKey, true);
}
}
13 changes: 12 additions & 1 deletion twister/tsconfig.build.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,18 @@
"declarationMap": true,
"sourceMap": true,
"noEmit": false,
"composite": false
"composite": false,
// src/exemplars/*.ts import "@plotday/twister" by package name (so the
// embedded LLM-facing examples show real import paths, not relative
// ones). Resolving that self-reference requires the same
// moduleResolution + custom condition tsconfig.base.json already grants
// downstream twist/connector projects for the reverse case (resolving
// @plotday/twister straight to this package's src/*). Scoped to this
// build-only config (not tsconfig.json / tsconfig.cli.json) since
// "bundler" resolution is incompatible with tsconfig.cli.json's
// CommonJS output (TS5095).
"moduleResolution": "bundler",
"customConditions": ["@plotday/connector"]
},
"include": ["src/**/*.ts"],
"exclude": [
Expand Down
3 changes: 2 additions & 1 deletion twister/typedoc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,12 @@
"src/utils/hash.ts"
],
"out": "dist/docs",
"tsconfig": "./tsconfig.json",
"tsconfig": "./tsconfig.build.json",
"exclude": [
"**/*+(.spec|.test).ts",
"**/llm-docs/**",
"**/cli/**",
"**/exemplars/**",
"src/twist-guide.ts",
"src/creator-docs.ts",
"prebuild.ts",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(twister): twist-scoped LLM docs and compiled example twists by KrisBraun · Pull Request #277 · plotday/plot · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twist-prompt-docs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: getTwistDocumentation() (twist-scoped LLM documentation that omits connector-only modules) and TWIST_EXEMPLARS (complete example twists, compiled and type-checked on every build) for generation prompts.
53 changes: 52 additions & 1 deletion twister/prebuild.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

Expand DownExpand Up@@ -189,6 +196,50 @@ export default ${JSON.stringify(twistsTemplateContent)};
);
}

// Generate twist-exemplars.ts from src/exemplars/*.ts — complete example
// twists embedded for LLM generation prompts. Each file must start with a
// /* SPEC: ... */ block comment (the user-style specification it implements).
const exemplarsDir = join(srcDir, "exemplars");
if (!existsSync(exemplarsDir)) {
throw new Error("prebuild: src/exemplars/ is missing");
}
const exemplarFiles = readdirSync(exemplarsDir)
.filter((f) => f.endsWith(".ts"))
.sort();
if (exemplarFiles.length === 0) {
throw new Error("prebuild: src/exemplars/ contains no exemplars");
}
const exemplarSections = exemplarFiles.map((file) => {
const raw = readFileSync(join(exemplarsDir, file), "utf-8");
const specMatch = raw.match(/^\/\*\s*SPEC:\s*\n([\s\S]*?)\*\/\s*\n/);
if (!specMatch) {
throw new Error(`prebuild: ${file} is missing its leading /* SPEC: */ comment`);
}
const spec = specMatch[1].trim();
const implementation = raw.slice(specMatch[0].length).trim();
const title = file
.replace(/\.ts$/, "")
.split("-")
// Word-capitalize each hyphen segment, except known acronyms (e.g. "ai"
// -> "AI" for ai-responder.ts) which are upper-cased outright.
.map((w) =>
w.toLowerCase() === "ai" ? "AI" : w[0].toUpperCase() + w.slice(1)
)
.join(" ");
return `## Example: ${title}\n\n### Specification\n\n${spec}\n\n### Implementation\n\n\`\`\`typescript\n${implementation}\n\`\`\``;
});
const exemplarsContent = `/**
* Generated example twists for LLM generation prompts.
*
* This file is auto-generated during build. Do not edit manually.
* Generated from: src/exemplars/*.ts
*/

export default ${JSON.stringify(exemplarSections.join("\n\n"))};
`;
writeFileSync(join(llmDocsDir, "twist-exemplars.ts"), exemplarsContent, "utf-8");
console.log(`✓ Generated twist-exemplars.ts from ${exemplarFiles.length} exemplars`);

console.log(
`✓ Generated ${typeFiles.length} LLM documentation files in src/llm-docs/`
);
39 changes: 39 additions & 0 deletions twister/src/creator-docs.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import llmDocs from "./llm-docs/index.js";
import twistExemplars from "./llm-docs/twist-exemplars.js";

/**
* Gets complete Twist Creator type definitions with import paths for LLM context.
Expand DownExpand Up@@ -27,3 +28,41 @@ export function getBuilderDocumentation(): string {

return documentation;
}

// Modules excluded from TWIST generation docs: twists must never extend
// Connector, and the mail-protocol tools are niche enough to dilute the
// prompt more than they help.
const TWIST_DOC_EXCLUSIONS = new Set([
"@plotday/twister/connector",
"@plotday/twister/tools/imap",
"@plotday/twister/tools/smtp",
]);

/**
* Twist-scoped variant of getBuilderDocumentation(): the same formatted
* type definitions, minus modules that are irrelevant (or misleading) when
* generating a twist.
*/
export function getTwistDocumentation(): string {
let documentation = "# Plot Twist Creator Type Definitions\n\n";
documentation +=
"Complete type definitions with JSDoc documentation for all Plot Twist Creator types.\n";
documentation +=
"These are the source files - use the import paths shown to import types in your twist code.\n\n";
for (const [importPath, content] of Object.entries(llmDocs)) {
if (TWIST_DOC_EXCLUSIONS.has(importPath)) continue;
documentation += `## ${importPath}\n\n`;
documentation += "```typescript\n";
documentation += `// Import from: ${importPath}\n\n`;
documentation += content;
documentation += "\n```\n\n";
}
return documentation;
}

/**
* Complete example twists (specification + implementation pairs) for LLM
* generation prompts. The examples are real compiled sources in
* src/exemplars/, so they are type-checked on every build.
*/
export const TWIST_EXEMPLARS: string = twistExemplars;
60 changes: 60 additions & 0 deletions twister/src/exemplars/ai-responder.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
/* SPEC:
When someone writes a journal note in another language, reply in the same
thread with a short plain-English summary of what they wrote, so they can
check their own understanding. Don't react to notes written by automations.
*/
import { ActorType, Twist, type Note, type ToolBuilder } from "@plotday/twister";
import { AI } from "@plotday/twister/tools/ai";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class LanguageJournal extends Twist<LanguageJournal> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
// onNoteCreated only fires for notes on threads this twist created,
// so Create access is required even though nothing else reads or
// updates other threads.
thread: { access: ThreadAccess.Create },
}),
ai: build(AI),
};
}

async activate() {
// Seed the standing thread the user writes journal entries into.
await this.tools.plot.createThread({
title: "Language journal",
notes: [
{
content:
"Write your journal entries here in any language — I'll reply with a plain-English summary.",
},
],
});
}

// Fires for every new note on a thread this twist created. Guard against
// notes from twists/automations so we never loop on our own replies.
async onNoteCreated(note: Note): Promise<void> {
if (note.author.type === ActorType.Twist) {
return;
}
if (!note.content || note.content.trim().length === 0) {
return;
}

const response = await this.tools.ai.prompt({
model: { speed: "fast", cost: "low" },
prompt: `Summarize this journal entry in one or two plain-English sentences:\n\n${note.content}`,
});
if (!response.text) {
return;
}

// Reply in the same thread the note belongs to.
await this.tools.plot.createNote({
thread: { id: note.thread.id },
content: `English summary: ${response.text}`,
});
}
}
84 changes: 84 additions & 0 deletions twister/src/exemplars/authenticated-sync.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
/* SPEC:
Connect to my bookmarking service account. Once connected, import my starred
bookmarks as threads (title + link note), and check for new ones every hour.
Imported bookmarks must not duplicate on re-sync.
*/
import { ActionType, Twist, type ToolBuilder, type Uuid } from "@plotday/twister";
import { Options } from "@plotday/twister/options";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class BookmarkSync extends Twist<BookmarkSync> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
// Plain twists can't hold OAuth tokens — that machinery
// (provider/scopes/channels) belongs to Connectors. A secure Options
// field is the twist-safe way to let the user "connect" an account
// for a twist to call directly.
options: build(Options, {
apiKey: {
type: "text",
label: "Bookmarking service API key",
default: "",
secure: true,
},
}),
network: build(Network, {
urls: ["https://api.bookmarks.example/*"],
}),
};
}

async activate() {
// Re-runs hourly under a stable key; survives restarts and upgrades.
await this.scheduleRecurring(
"hourly-sync",
await this.callback(this.sync),
{ intervalMs: 60 * 60 * 1000 }
);
await this.sync();
}

async sync(): Promise<void> {
const { apiKey } = this.tools.options;
if (!apiKey) {
return; // Not connected yet; nothing to sync.
}

const response = await fetch("https://api.bookmarks.example/v1/starred", {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
return;
}
const { bookmarks } = (await response.json()) as {
bookmarks: Array<{ id: string; title: string; url: string }>;
};

// Threads a twist creates have no external Link (that's a Connector
// concept), so dedup is tracked manually: store the bookmark id -> thread
// id mapping and skip any bookmark that's already been imported.
for (const bookmark of bookmarks) {
const mappingKey = `bookmark:${bookmark.id}`;
if (await this.get<Uuid>(mappingKey)) {
continue;
}

const threadId = await this.tools.plot.createThread({
title: bookmark.title,
notes: [
{
content: bookmark.url,
actions: [
{ type: ActionType.external, title: "Open bookmark", url: bookmark.url },
],
},
],
});
await this.set(mappingKey, threadId);
}
}
}
61 changes: 61 additions & 0 deletions twister/src/exemplars/scheduled-digest.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
/* SPEC:
Every morning, post a thread with today's weather forecast for my city so I
can plan the day. One thread per day, titled with the date.
*/
import { Twist, type ToolBuilder } from "@plotday/twister";
import { Network } from "@plotday/twister/tools/network";
import { Plot, ThreadAccess } from "@plotday/twister/tools/plot";

export default class WeatherDigest extends Twist<WeatherDigest> {
build(build: ToolBuilder) {
return {
plot: build(Plot, {
thread: { access: ThreadAccess.Create },
}),
network: build(Network, {
urls: ["https://api.open-meteo.com/*"],
}),
};
}

async activate() {
await this.scheduleRecurring(
"morning-digest",
await this.callback(this.postDigest),
{ intervalMs: 24 * 60 * 60 * 1000 }
);
}

async postDigest(): Promise<void> {
// Scheduled callbacks can fire more than once (at-least-once delivery),
// so guard "one thread per day" with a stored marker keyed on the date.
const today = new Date().toISOString().slice(0, 10);
const postedKey = `posted:${today}`;
if (await this.get<boolean>(postedKey)) {
return;
}

const response = await fetch(
"https://api.open-meteo.com/v1/forecast?latitude=43.65&longitude=-79.38&daily=temperature_2m_max,precipitation_probability_mean&timezone=auto&forecast_days=1"
);
if (!response.ok) {
return;
}
const data = (await response.json()) as {
daily: {
temperature_2m_max: number[];
precipitation_probability_mean: number[];
};
};

await this.tools.plot.createThread({
title: `Weather for ${today}`,
notes: [
{
content: `High of ${data.daily.temperature_2m_max[0]}°C, ${data.daily.precipitation_probability_mean[0]}% chance of rain.`,
},
],
});
await this.set(postedKey, true);
}
}
13 changes: 12 additions & 1 deletion twister/tsconfig.build.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,18 @@
"declarationMap": true,
"sourceMap": true,
"noEmit": false,
"composite": false
"composite": false,
// src/exemplars/*.ts import "@plotday/twister" by package name (so the
// embedded LLM-facing examples show real import paths, not relative
// ones). Resolving that self-reference requires the same
// moduleResolution + custom condition tsconfig.base.json already grants
// downstream twist/connector projects for the reverse case (resolving
// @plotday/twister straight to this package's src/*). Scoped to this
// build-only config (not tsconfig.json / tsconfig.cli.json) since
// "bundler" resolution is incompatible with tsconfig.cli.json's
// CommonJS output (TS5095).
"moduleResolution": "bundler",
"customConditions": ["@plotday/connector"]
},
"include": ["src/**/*.ts"],
"exclude": [
Expand Down
3 changes: 2 additions & 1 deletion twister/typedoc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,12 @@
"src/utils/hash.ts"
],
"out": "dist/docs",
"tsconfig": "./tsconfig.json",
"tsconfig": "./tsconfig.build.json",
"exclude": [
"**/*+(.spec|.test).ts",
"**/llm-docs/**",
"**/cli/**",
"**/exemplars/**",
"src/twist-guide.ts",
"src/creator-docs.ts",
"prebuild.ts",
Expand Down
Loading