diff --git a/README.md b/README.md index e8d20f4..a432eb8 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ codex Open `/plugins`, select the Ponytail marketplace, and install Ponytail. Then open `/hooks`, review and trust its two lifecycle hooks, and start a new thread. +### Pi agent harness + +``` +pi install git:github.com/DietrichGebert/ponytail +``` + That was it. He'd be proud. He won't say it. Active every session. `/ponytail-review` finds what to delete in your diff. `/ponytail ultra` exists for when the codebase has wronged you personally. `/ponytail-help` explains the rest. diff --git a/hooks/ponytail-activate.js b/hooks/ponytail-activate.js index cca08ae..be0c1b1 100644 --- a/hooks/ponytail-activate.js +++ b/hooks/ponytail-activate.js @@ -10,6 +10,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const { getDefaultMode } = require('./ponytail-config'); +const { getPonytailInstructions } = require('./ponytail-instructions'); const { clearMode, isCodex, @@ -37,99 +38,7 @@ try { } // 2. Emit the ponytail ruleset, filtered to the active intensity level. -// A short summary is too weak — models drift back to over-building -// mid-conversation, especially after context compression prunes it. -// Full rules with examples anchor behavior much more reliably. -// -// Reads SKILL.md at runtime so edits to the source of truth propagate -// automatically — no hardcoded duplication to go stale. - -// Modes that have their own independent skill files — not intensity levels. -// For these, emit a short activation line; the skill itself handles behavior. -const INDEPENDENT_MODES = new Set(['review']); - -if (INDEPENDENT_MODES.has(mode)) { - writeHookOutput( - 'SessionStart', - mode, - 'PONYTAIL MODE ACTIVE — level: ' + mode + '. Behavior defined by /ponytail-' + mode + ' skill.', - ); - process.exit(0); -} - -// Read SKILL.md — the single source of truth for ponytail behavior. -// Plugin installs: __dirname = /hooks/, SKILL.md at /skills/ponytail/SKILL.md -let skillContent = ''; -try { - skillContent = fs.readFileSync( - path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md'), 'utf8' - ); -} catch (e) { /* standalone install — will use fallback below */ } - -let output; - -if (skillContent) { - // Strip YAML frontmatter - const body = skillContent.replace(/^---[\s\S]*?---\s*/, ''); - - // Filter intensity table: keep header rows + only the active level's row - const filtered = body.split('\n').reduce((acc, line) => { - // Intensity table rows start with | **level** | - const tableRowMatch = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/); - if (tableRowMatch) { - if (tableRowMatch[1] === mode) { - acc.push(line); - } - return acc; - } - - // Example lines start with "- level:" — keep only lines matching active level - const exampleMatch = line.match(/^- (\S+?):\s/); - if (exampleMatch) { - if (exampleMatch[1] === mode) { - acc.push(line); - } - return acc; - } - - acc.push(line); - return acc; - }, []); - - output = 'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' + filtered.join('\n'); -} else { - // Fallback when SKILL.md is not found (hook installed without skills dir). - // Minimum viable ruleset — better than nothing. - output = - 'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' + - 'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.\n\n' + - '## Persistence\n\n' + - 'ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode".\n\n' + - 'Current level: **' + mode + '**. Switch: `/ponytail lite|full|ultra`.\n\n' + - '## The ladder\n\n' + - 'Before any code, stop at the first rung that holds:\n' + - '1. Does this need to be built at all? (YAGNI)\n' + - '2. Does the standard library do this? Use it.\n' + - '3. Does a native platform feature cover it? Use it.\n' + - '4. Does an already-installed dependency solve it? Use it.\n' + - '5. Can this be one line? Make it one line.\n' + - '6. Only then: write the minimum code that works.\n\n' + - '## Rules\n\n' + - 'No abstractions that were not requested. No avoidable dependencies. No boilerplate nobody asked for. ' + - 'Deletion over addition. Boring over clever. Fewest files possible. ' + - 'Ship the lazy version and question the complex request in the same response — never stall. ' + - 'Between two same-size stdlib options, pick the one correct on edge cases. ' + - 'Mark intentional simplifications with a `ponytail:` comment — a shortcut with a known ceiling names the ceiling and the upgrade path in the comment.\n\n' + - '## Output\n\n' + - 'Code first. Then at most three short lines: what was skipped, when to add it. ' + - 'If the explanation is longer than the code, delete the explanation.\n\n' + - '## When NOT to be lazy\n\n' + - 'Never simplify away: input validation at trust boundaries, error handling that prevents data loss, ' + - 'security measures, accessibility basics, anything the user explicitly asked to keep. ' + - 'Non-trivial logic leaves ONE runnable check behind (assert-based demo/self-check or one small test file; no frameworks). Trivial one-liners need no test.\n\n' + - '## Boundaries\n\n' + - 'Ponytail governs what you build, not how you talk. "stop ponytail" or "normal mode": revert. Level persists until changed or session end.'; -} +let output = getPonytailInstructions(mode); // 3. Detect missing statusline config — nudge Claude to help set it up if (!isCodex) try { diff --git a/hooks/ponytail-config.js b/hooks/ponytail-config.js index d3dead5..43dbcc1 100644 --- a/hooks/ponytail-config.js +++ b/hooks/ponytail-config.js @@ -13,7 +13,25 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +const DEFAULT_MODE = 'full'; const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review']; +const RUNTIME_MODES = ['off', 'lite', 'full', 'ultra']; + +function normalizeMode(mode) { + if (typeof mode !== 'string') return null; + const normalized = mode.trim().toLowerCase(); + return RUNTIME_MODES.includes(normalized) ? normalized : null; +} + +function normalizeConfigMode(mode) { + if (typeof mode !== 'string') return null; + const normalized = mode.trim().toLowerCase(); + return VALID_MODES.includes(normalized) ? normalized : null; +} + +function normalizePersistedMode(mode) { + return normalizeMode(mode) || normalizeConfigMode(mode); +} function getConfigDir() { if (process.env.XDG_CONFIG_HOME) { @@ -51,7 +69,28 @@ function getDefaultMode() { } // 3. Default - return 'full'; + return DEFAULT_MODE; } -module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES }; +function writeDefaultMode(mode) { + const normalized = normalizeConfigMode(mode); + if (!normalized) return null; + + const configPath = getConfigPath(); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ defaultMode: normalized }, null, 2), 'utf8'); + return normalized; +} + +module.exports = { + DEFAULT_MODE, + VALID_MODES, + RUNTIME_MODES, + getDefaultMode, + getConfigDir, + getConfigPath, + normalizeMode, + normalizeConfigMode, + normalizePersistedMode, + writeDefaultMode, +}; diff --git a/hooks/ponytail-instructions.js b/hooks/ponytail-instructions.js new file mode 100644 index 0000000..c68e600 --- /dev/null +++ b/hooks/ponytail-instructions.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Shared Ponytail instruction builder for Claude hooks and Pi extension. + +const fs = require('fs'); +const path = require('path'); +const { DEFAULT_MODE, normalizeMode, normalizePersistedMode } = require('./ponytail-config'); + +const INDEPENDENT_MODES = new Set(['review']); +const SKILL_PATH = path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md'); + +function filterSkillBodyForMode(body, mode) { + const effectiveMode = normalizeMode(mode) || DEFAULT_MODE; + const withoutFrontmatter = String(body || '').replace(/^---[\s\S]*?---\s*/, ''); + + return withoutFrontmatter + .split(/\r?\n/) + .filter((line) => { + const tableMatch = line.match(/^\|\s*\*\*(.+?)\*\*\s*\|/); + if (tableMatch) return tableMatch[1].trim() === effectiveMode; + + const exampleMatch = line.match(/^-\s*([^:]+):\s*/); + if (exampleMatch) return exampleMatch[1].trim() === effectiveMode; + + return true; + }) + .join('\n'); +} + +function getFallbackInstructions(mode) { + return 'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' + + 'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.\n\n' + + '## Persistence\n\n' + + 'ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode".\n\n' + + 'Current level: **' + mode + '**. Switch: `/ponytail lite|full|ultra`.\n\n' + + '## The ladder\n\n' + + 'Before any code, stop at the first rung that holds:\n' + + '1. Does this need to be built at all? (YAGNI)\n' + + '2. Does the standard library do this? Use it.\n' + + '3. Does a native platform feature cover it? Use it.\n' + + '4. Does an already-installed dependency solve it? Use it.\n' + + '5. Can this be one line? Make it one line.\n' + + '6. Only then: write the minimum code that works.\n\n' + + '## Rules\n\n' + + 'No abstractions that were not requested. No avoidable dependencies. No boilerplate nobody asked for. ' + + 'Deletion over addition. Boring over clever. Fewest files possible. ' + + 'Ship the lazy version and question the complex request in the same response — never stall. ' + + 'Between two same-size stdlib options, pick the one correct on edge cases. ' + + 'Mark intentional simplifications with a `ponytail:` comment — a shortcut with a known ceiling names the ceiling and the upgrade path in the comment.\n\n' + + '## Output\n\n' + + 'Code first. Then at most three short lines: what was skipped, when to add it. ' + + 'If the explanation is longer than the code, delete the explanation.\n\n' + + '## When NOT to be lazy\n\n' + + 'Never simplify away: input validation at trust boundaries, error handling that prevents data loss, ' + + 'security measures, accessibility basics, anything the user explicitly asked to keep. ' + + 'Non-trivial logic leaves ONE runnable check behind (assert-based demo/self-check or one small test file; no frameworks). Trivial one-liners need no test.\n\n' + + '## Boundaries\n\n' + + 'Ponytail governs what you build, not how you talk. "stop ponytail" or "normal mode": revert. Level persists until changed or session end.'; +} + +function getPonytailInstructions(mode) { + const configuredMode = normalizePersistedMode(mode) || DEFAULT_MODE; + + if (INDEPENDENT_MODES.has(configuredMode)) { + return 'PONYTAIL MODE ACTIVE — level: ' + configuredMode + '. Behavior defined by /ponytail-' + configuredMode + ' skill.'; + } + + const effectiveMode = normalizeMode(configuredMode) || DEFAULT_MODE; + + try { + return 'PONYTAIL MODE ACTIVE — level: ' + effectiveMode + '\n\n' + + filterSkillBodyForMode(fs.readFileSync(SKILL_PATH, 'utf8'), effectiveMode); + } catch (e) { + return getFallbackInstructions(effectiveMode); + } +} + +module.exports = { + filterSkillBodyForMode, + getFallbackInstructions, + getPonytailInstructions, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..5a5ec4b --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "ponytail", + "version": "0.1.0", + "description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.", + "keywords": ["pi-package", "pi", "skills", "ponytail"], + "license": "MIT", + "pi": { + "extensions": ["./pi-extension/index.js"], + "skills": ["./skills"] + } +} diff --git a/pi-extension/index.js b/pi-extension/index.js new file mode 100644 index 0000000..b75cc65 --- /dev/null +++ b/pi-extension/index.js @@ -0,0 +1,141 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + DEFAULT_MODE, + getDefaultMode, + normalizeMode, + normalizeConfigMode, + normalizePersistedMode, + writeDefaultMode, +} = require("../hooks/ponytail-config.js"); +const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js"); + +export { filterSkillBodyForMode }; +export const readDefaultMode = getDefaultMode; + +export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) { + const fallback = normalizePersistedMode(fallbackMode) || DEFAULT_MODE; + if (!Array.isArray(entries)) return fallback; + + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (entry?.type !== "custom" || entry?.customType !== "ponytail-mode") continue; + + const mode = normalizePersistedMode(entry?.data?.mode); + if (mode) return mode; + } + + return fallback; +} + +export function parsePonytailCommand(text, defaultMode = DEFAULT_MODE) { + const fallback = normalizePersistedMode(defaultMode) || DEFAULT_MODE; + const normalizedText = String(text || "").trim().toLowerCase(); + + if (!normalizedText) { + return { type: "set-mode", mode: fallback === "off" ? "full" : fallback }; + } + + const [primary, secondary] = normalizedText.split(/\s+/); + + if (primary === "status") return { type: "status" }; + + if (primary === "default") { + const mode = normalizeConfigMode(secondary); + return mode ? { type: "set-default", mode } : { type: "invalid", reason: "invalid-default-mode" }; + } + + const mode = normalizeMode(primary); + return mode ? { type: "set-mode", mode } : { type: "invalid", reason: "invalid-mode", mode: primary }; +} + +export { writeDefaultMode }; + +export default function ponytailExtension(pi) { + let currentMode = DEFAULT_MODE; + let configuredDefaultMode = getDefaultMode(); + + const setMode = (mode, ctx) => { + const normalized = normalizePersistedMode(mode); + if (!normalized) return; + + currentMode = normalized; + pi.appendEntry("ponytail-mode", { mode: normalized }); + ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info"); + }; + + const sendAlias = (skillName, args, ctx) => { + const normalized = String(args || "").trim(); + const message = normalized ? `${skillName} ${normalized}` : skillName; + + if (ctx?.isIdle?.() === false) { + pi.sendUserMessage(message, { deliverAs: "followUp" }); + ctx?.ui?.notify?.(`${skillName} queued as follow-up.`, "info"); + return; + } + + pi.sendUserMessage(message); + }; + + pi.registerCommand("ponytail", { + description: "Set or report Ponytail mode", + handler: async (args, ctx) => { + const parsed = parsePonytailCommand(args, configuredDefaultMode); + + if (parsed.type === "status") { + ctx?.ui?.notify?.(`Ponytail: current ${currentMode} • default ${configuredDefaultMode}`, "info"); + return; + } + + if (parsed.type === "set-default") { + const written = writeDefaultMode(parsed.mode); + if (written) { + configuredDefaultMode = getDefaultMode(); + const message = configuredDefaultMode === written + ? `Default Ponytail mode set to ${written}.` + : `Saved default ${written}, but env override keeps default at ${configuredDefaultMode}.`; + ctx?.ui?.notify?.(message, "info"); + } + return; + } + + if (parsed.type === "set-mode") { + setMode(parsed.mode, ctx); + return; + } + + ctx?.ui?.notify?.("Unknown or unsupported /ponytail mode.", "warning"); + }, + }); + + pi.registerCommand("ponytail-review", { + description: "Run /skill:ponytail-review", + handler: (_args, ctx) => sendAlias("/skill:ponytail-review", "", ctx), + }); + + pi.registerCommand("ponytail-help", { + description: "Run /skill:ponytail-help", + handler: (_args, ctx) => sendAlias("/skill:ponytail-help", "", ctx), + }); + + pi.on("input", async (event) => { + if (event?.source === "extension") return; + + const text = String(event?.text || ""); + if (currentMode !== "off" && /\b(stop ponytail|normal mode)\b/i.test(text)) { + setMode("off"); + } + }); + + pi.on("session_start", async (_event, ctx) => { + const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || []; + configuredDefaultMode = getDefaultMode(); + currentMode = resolveSessionMode(entries, configuredDefaultMode); + }); + + pi.on("before_agent_start", async (event) => { + if (!currentMode || currentMode === "off") return; + return { systemPrompt: `${event.systemPrompt}\n\n${getPonytailInstructions(currentMode)}` }; + }); +} diff --git a/pi-extension/package.json b/pi-extension/package.json new file mode 100644 index 0000000..745bde2 --- /dev/null +++ b/pi-extension/package.json @@ -0,0 +1,8 @@ +{ + "name": "ponytail-pi-extension-dev", + "private": true, + "type": "module", + "scripts": { + "test": "node --test ./test/*.test.js" + } +} diff --git a/pi-extension/test/extension.test.js b/pi-extension/test/extension.test.js new file mode 100644 index 0000000..76cf15d --- /dev/null +++ b/pi-extension/test/extension.test.js @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import ponytailExtension from "../index.js"; + +function createPiHarness() { + const events = new Map(); + const commands = new Map(); + const appendedEntries = []; + const sentUserMessages = []; + + const pi = { + on(eventName, handler) { + events.set(eventName, handler); + }, + registerCommand(name, options) { + commands.set(name, options); + }, + appendEntry(customType, data) { + appendedEntries.push({ customType, data }); + }, + sendUserMessage(text, options) { + sentUserMessages.push({ text, options }); + }, + }; + + ponytailExtension(pi); + return { events, commands, appendedEntries, sentUserMessages }; +} + +function createCommandContext(overrides = {}) { + return { + isIdle: () => true, + sessionManager: { getEntries: () => [] }, + ui: { notify() {} }, + ...overrides, + }; +} + +function withTempConfig(fn) { + const tempConfigHome = mkdtempSync(join(tmpdir(), "ponytail-test-")); + const previousXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = tempConfigHome; + + return Promise.resolve() + .then(fn) + .finally(() => { + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousXdg; + rmSync(tempConfigHome, { recursive: true, force: true }); + }); +} + +test("extension registers Ponytail commands", () => { + const { commands } = createPiHarness(); + + assert.deepEqual([...commands.keys()].sort(), ["ponytail", "ponytail-help", "ponytail-review"]); +}); + +test("/ponytail updates session mode and injects instructions", async () => withTempConfig(async () => { + const { commands, events, appendedEntries } = createPiHarness(); + const ctx = createCommandContext(); + + await events.get("session_start")({ reason: "startup" }, ctx); + await commands.get("ponytail").handler("ultra", ctx); + + assert.deepEqual(appendedEntries.at(-1), { + customType: "ponytail-mode", + data: { mode: "ultra" }, + }); + + const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx); + assert.ok(result.systemPrompt.includes("PONYTAIL MODE ACTIVE")); + assert.ok(result.systemPrompt.includes("ultra")); +})); + +test("session_start restores latest persisted mode", async () => withTempConfig(async () => { + const { events } = createPiHarness(); + const ctx = createCommandContext({ + sessionManager: { + getEntries: () => [ + { type: "custom", customType: "ponytail-mode", data: { mode: "lite" } }, + ], + }, + }); + + await events.get("session_start")({ reason: "resume" }, ctx); + const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx); + + assert.ok(result.systemPrompt.includes("lite")); +})); + +test("skill alias commands delegate to Pi skill commands", async () => { + const { commands, sentUserMessages } = createPiHarness(); + const ctx = createCommandContext(); + + await commands.get("ponytail-review").handler("", ctx); + await commands.get("ponytail-help").handler("", ctx); + + assert.deepEqual(sentUserMessages.map((entry) => entry.text), [ + "/skill:ponytail-review", + "/skill:ponytail-help", + ]); +}); + +test("normal mode disables persistent instructions", async () => withTempConfig(async () => { + const { commands, events } = createPiHarness(); + const ctx = createCommandContext(); + + await events.get("session_start")({ reason: "startup" }, ctx); + await commands.get("ponytail").handler("ultra", ctx); + await events.get("input")({ text: "normal mode", source: "interactive" }, ctx); + + const disabled = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx); + assert.equal(disabled, undefined); +})); diff --git a/pi-extension/test/helpers.test.js b/pi-extension/test/helpers.test.js new file mode 100644 index 0000000..8871cce --- /dev/null +++ b/pi-extension/test/helpers.test.js @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + filterSkillBodyForMode, + parsePonytailCommand, + readDefaultMode, + resolveSessionMode, + writeDefaultMode, +} from "../index.js"; + +test("parsePonytailCommand falls back to full when invoked bare and default is off", () => { + assert.deepEqual(parsePonytailCommand("", "off"), { type: "set-mode", mode: "full" }); +}); + +test("parsePonytailCommand parses modes, status, and default subcommand", () => { + assert.deepEqual(parsePonytailCommand("ultra", "full"), { type: "set-mode", mode: "ultra" }); + assert.deepEqual(parsePonytailCommand("status", "full"), { type: "status" }); + assert.deepEqual(parsePonytailCommand("default lite", "full"), { type: "set-default", mode: "lite" }); +}); + +test("resolveSessionMode prefers latest persisted session mode", () => { + const entries = [ + { type: "custom", customType: "ponytail-mode", data: { mode: "lite" } }, + { type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }, + ]; + + assert.equal(resolveSessionMode(entries, "full"), "ultra"); +}); + +test("readDefaultMode and writeDefaultMode use XDG config path", () => { + const tempDir = mkdtempSync(join(tmpdir(), "ponytail-config-")); + const previousXdg = process.env.XDG_CONFIG_HOME; + const previousDefault = process.env.PONYTAIL_DEFAULT_MODE; + const configPath = join(tempDir, "ponytail", "config.json"); + process.env.XDG_CONFIG_HOME = tempDir; + delete process.env.PONYTAIL_DEFAULT_MODE; + + try { + assert.equal(readDefaultMode(), "full"); + assert.equal(writeDefaultMode("ultra"), "ultra"); + assert.equal(readDefaultMode(), "ultra"); + assert.ok(existsSync(configPath)); + assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { defaultMode: "ultra" }); + } finally { + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousXdg; + if (previousDefault === undefined) delete process.env.PONYTAIL_DEFAULT_MODE; + else process.env.PONYTAIL_DEFAULT_MODE = previousDefault; + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("filterSkillBodyForMode keeps only requested intensity examples and rows", () => { + const body = `---\nname: ponytail\n---\n| **lite** | keep lite |\n| **full** | keep full |\n| **ultra** | keep ultra |\n- lite: Lite example\n- full: Full example\n- ultra: Ultra example\nOther line`; + + const filtered = filterSkillBodyForMode(body, "ultra"); + + assert.ok(!filtered.includes("keep lite")); + assert.ok(!filtered.includes("keep full")); + assert.ok(filtered.includes("keep ultra")); + assert.ok(!filtered.includes("Lite example")); + assert.ok(filtered.includes("Ultra example")); + assert.ok(filtered.includes("Other line")); +});