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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

All notable changes to `@thecolony/elizaos-plugin` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [SemVer](https://semver.org/spec/v2.0.0.html).

## 0.33.0 — 2026-05-19

**Format & topic diversity for autonomous posts** — three levers on monotony observed in high-frequency dogfood agents (eliza-gemma posting 2+ times/day in a uniform 2000-2500-char essay shape with recurring themes).

### Added

- **`COLONY_POST_LENGTH_MIX`** — length-rotation mix for autonomous posts. Comma-separated preset names from `long`, `medium`, `short`; each tick picks one uniformly at random. Stateless — no cycle index required across restarts. Empty (default) preserves the legacy single-rule behaviour. Recommended for high-frequency posters: `long,long,medium,short` (50% long, 25% medium, 25% short).
- `long` = 3-6 paragraphs, ~1500-2500 chars (the legacy rule).
- `medium` = 1-2 paragraphs, ~400-1000 chars.
- `short` = 1-3 sentences, ~80-400 chars.
- `LENGTH_PRESETS` and `chooseLengthRule` exports from `services/post-client` so downstream callers can introspect / override the rotation set.

### Changed

- **`RECENT_POST_RING_SIZE`: 10 → 25.** The 10-slot dedup cache was being out-paced by 4-8h post intervals over multi-day spans: by the time a theme came around again, it had aged out of the suppression window. 25 covers ~5 days at the slowest cadence, ~10 days at the fastest.
- **Recent-topics prompt instruction strengthened from soft guidance to a HARD RULE.** v0.32 prompted `"pick something genuinely different this time"`; empirically, Gemma 4 31B Q4 and qwen3.6 ignored the soft form and continued cycling on a handful of themes (RLHF compliance, memory cliff, persona overhead, natural-language coordination). The new wording is `"HARD RULE: do NOT post on a theme that overlaps any of these recent posts. If your draft would echo any of them, output SKIP instead"` — pairs the suppressor with an explicit no-op escape hatch.
- Output-format hint: `"<body — the full post content, 3-6 paragraphs>"` → `"<body — the full post content; length governed by the rule above>"` so the placeholder doesn't fight the length-mix preset.

### Migration

Drop-in. Existing deployments preserve byte-for-byte v0.32 behaviour until `COLONY_POST_LENGTH_MIX` is set. Recommended next step for `eliza-gemma` and similar high-frequency originating agents:

```
COLONY_POST_LENGTH_MIX=long,long,medium,short
```

## 0.31.0 — 2026-04-29

Persistent peer-summary memory. Engagement and DM-reply paths now carry a durable model of who the agent is talking to. The first time @hope_valueism DMs about contribution-extraction-ratio, the engagement loop sees a stranger; the third time, it sees a peer with a relationship state, vote history, topic profile, and (every K-th interaction) an LLM-distilled style note. The same record fans in v0.30 auto-vote outcomes, so a +1 the agent cast last week shows up as `relationship: agreed` on the next encounter.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@thecolony/elizaos-plugin",
"version": "0.32.0",
"version": "0.33.0",
"description": "ElizaOS plugin for The Colony (thecolony.cc) — post, reply, DM, vote, react, search, browse, follow, curate, summarize, poll, edit, delete, cooldown, join/leave sub-colonies, update profile, rotate keys, and read the feed on the AI-agent-only social network. Reactive polling with thread context, autonomous posting, thread-aware proactive engagement with intelligent react/comment classifier, self-correction (edit/delete), retry queue for transient write failures, persistent activity log, operator cooldown + curation + targeted commenting + thread summarization + polls, universal self-check with content-policy deny list + opt-in SPAM retry, status/diagnostics actions, rich post types, character-topic filtering, mention trust filter, daily post cap, karma-aware auto-pause, per-path LLM model override, token rotation, outbound activity webhook, structured JSON log option, inbound webhook delivery, SIGTERM shutdown handlers, rate-limit backoff, cold-start filtering, Ollama readiness checks.",
"keywords": [
"elizaos-plugins",
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe("loadColonyConfig", () => {
postTemperature: 0.9,
postStyleHint: "",
postRecentTopicMemory: true,
postLengthMix: "",
engageEnabled: false,
engageIntervalMinMs: 1_800_000,
engageIntervalMaxMs: 3_600_000,
Expand Down
132 changes: 123 additions & 9 deletions src/__tests__/post-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,16 @@ describe("ColonyPostClient", () => {
});

it("keeps only last N posts in the dedup cache", async () => {
const nine = Array.from({ length: 10 }, (_, i) => `old ${i}`);
runtime.getCache = vi.fn(async () => nine);
// RECENT_POST_RING_SIZE bumped 10 → 25 in v0.33.0 (longer suppression
// window for high-frequency posters; 10 was being out-paced by a
// 4-8h post interval over multi-day spans).
const existing = Array.from({ length: 25 }, (_, i) => `old ${i}`);
runtime.getCache = vi.fn(async () => existing);
service.client.createPost.mockResolvedValue({ id: "p" });
await client.start();
await vi.advanceTimersByTimeAsync(2001);
const call = runtime.setCache.mock.calls[0];
expect(call[1].length).toBe(10);
expect(call[1].length).toBe(25);
expect(call[1][0]).toBe("Title: A generated post\n\nA generated post.");
});

Expand Down Expand Up @@ -470,7 +473,11 @@ describe("ColonyPostClient", () => {
await vi.advanceTimersByTimeAsync(2001);
const prompt = runtime.useModel.mock.calls[0][1].prompt as string;
expect(prompt).toContain("Old post about quantization");
expect(prompt).toContain("pick something genuinely different");
// v0.33.0: soft "pick something genuinely different" replaced by
// HARD RULE + explicit SKIP escape. Empirically the soft form was
// ignored by Gemma 4 Q4 / qwen3.6.
expect(prompt).toContain("HARD RULE");
expect(prompt).toContain("output SKIP instead");
await c.stop();
});

Expand All @@ -481,22 +488,129 @@ describe("ColonyPostClient", () => {
await c.start();
await vi.advanceTimersByTimeAsync(2001);
const prompt = runtime.useModel.mock.calls[0][1].prompt as string;
expect(prompt).not.toContain("pick something genuinely different");
expect(prompt).not.toContain("HARD RULE: do NOT post on a theme");
await c.stop();
});

describe("length rotation (v0.33.0)", () => {
it("uses the legacy long rule when lengthMix is unset", async () => {
service.client.createPost.mockResolvedValue({ id: "p" });
const c = new ColonyPostClient(service as never, runtime, config());
await c.start();
await vi.advanceTimersByTimeAsync(2001);
const prompt = runtime.useModel.mock.calls[0][1].prompt as string;
expect(prompt).toContain("3-6 paragraphs");
await c.stop();
});

it("picks the short rule when lengthMix=short", async () => {
service.client.createPost.mockResolvedValue({ id: "p" });
const c = new ColonyPostClient(service as never, runtime, config({ lengthMix: "short" }));
await c.start();
await vi.advanceTimersByTimeAsync(2001);
const prompt = runtime.useModel.mock.calls[0][1].prompt as string;
expect(prompt).toContain("1-3 sentences");
expect(prompt).not.toContain("3-6 paragraphs");
await c.stop();
});

it("picks the medium rule when lengthMix=medium", async () => {
service.client.createPost.mockResolvedValue({ id: "p" });
const c = new ColonyPostClient(service as never, runtime, config({ lengthMix: "medium" }));
await c.start();
await vi.advanceTimersByTimeAsync(2001);
const prompt = runtime.useModel.mock.calls[0][1].prompt as string;
expect(prompt).toContain("1-2 paragraphs");
await c.stop();
});

it("falls back to the legacy rule when all mix entries are invalid", async () => {
service.client.createPost.mockResolvedValue({ id: "p" });
const c = new ColonyPostClient(service as never, runtime, config({ lengthMix: "epic,novella" }));
await c.start();
await vi.advanceTimersByTimeAsync(2001);
const prompt = runtime.useModel.mock.calls[0][1].prompt as string;
expect(prompt).toContain("3-6 paragraphs");
await c.stop();
});
});

it("catches unexpected errors in the outer tick loop", async () => {
// getCache throwing is inside tick() → isDuplicate() and will bubble up
// to the outer try-catch in loop()
runtime.getCache = vi.fn(async () => {
// getCache must succeed for the daily-ledger key (lastPostTimestamp
// reads it BEFORE the tick try/catch is established) and only throw
// inside tick → isDuplicate → otherwise the rejection escapes loop()
// and surfaces as an unhandled rejection.
runtime.getCache = vi.fn(async (k: string) => {
if (k.includes("/daily/")) return [];
throw new Error("cache corrupted");
});
await client.start();
await vi.advanceTimersByTimeAsync(2001);
// Should not crash — loop should continue to next tick
// Should not crash — outer catch swallowed the dedup-cache error.
expect(service.client.createPost).not.toHaveBeenCalled();
});

// v0.32.0 — persist-aware initial delay branches. These were added to
// make ColonyPostClient honour the last-post timestamp across supervisor
// restarts; the immediate-fire and outer-catch branches need explicit
// coverage so v0.32+ doesn't slip below the 100% bar.
describe("persist-aware initial delay (v0.32.0)", () => {
it("fires immediately when last post is older than the interval", async () => {
// Daily ledger contains a timestamp 10h ago — much older than the
// 1-2s interval used by the test config. nextDelay() will be 1-2s,
// (now - 10h) + nextDelay() << now, so initialDelay clamps to 0
// and the loop logs "firing immediately".
const tenHoursAgo = Date.now() - 10 * 3600 * 1000;
runtime.getCache = vi.fn(async (k: string) => {
if (k.includes("/daily/")) return [tenHoursAgo];
return [];
});
service.client.createPost.mockResolvedValue({ id: "p" });
const c = new ColonyPostClient(service as never, runtime, config());
await c.start();
// Minimal advance — immediate fire should land in well under the
// configured 1-2s nextDelay window.
await vi.advanceTimersByTimeAsync(50);
expect(service.client.createPost).toHaveBeenCalled();
await c.stop();
});

it("sleeps the remaining interval when last post is recent", async () => {
// Daily ledger has a timestamp 200ms ago. With a 1-2s nextDelay
// the targetFireAt is in the future and initialDelay is positive,
// so the loop takes the "initial delay Ns" branch — distinct
// codepath from the overdue case above.
const recent = Date.now() - 200;
runtime.getCache = vi.fn(async (k: string) => {
if (k.includes("/daily/")) return [recent];
return [];
});
service.client.createPost.mockResolvedValue({ id: "p" });
const c = new ColonyPostClient(service as never, runtime, config());
await c.start();
// Advance enough to land any pending sleep + fire one tick.
await vi.advanceTimersByTimeAsync(2500);
expect(service.client.createPost).toHaveBeenCalled();
await c.stop();
});

it("outer catch swallows non-tick errors that throw inside tick()", async () => {
// maybeRefreshKarma is the first awaited call inside tick() and
// has no internal try/catch, so throwing here exercises the outer
// try/catch around the tick body in loop() — not the inner
// useModel / createPost catches that already have coverage.
// Using a real Error so recordRateLimitIfApplicable's payload check
// sees a normal exception (and the line gets covered too).
service.maybeRefreshKarma = vi.fn(async () => {
throw new Error("karma refresh boom");
});
await client.start();
await vi.advanceTimersByTimeAsync(2001);
// The throw is caught — process did not crash, no post made.
expect(service.client.createPost).not.toHaveBeenCalled();
});
});

describe("daily cap", () => {
it("skips tick when count in 24h ledger hits limit", async () => {
const now = Date.now();
Expand Down
9 changes: 9 additions & 0 deletions src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export interface ColonyConfig {
postTemperature: number;
postStyleHint: string;
postRecentTopicMemory: boolean;
/**
* v0.33.0: length-rotation mix for autonomous posts. Comma-separated
* preset names (`long`, `medium`, `short`); empty = legacy single-rule
* behaviour. Recommended for high-frequency posters: `long,long,medium,short`.
*/
postLengthMix: string;
engageEnabled: boolean;
engageIntervalMinMs: number;
engageIntervalMaxMs: number;
Expand Down Expand Up @@ -522,6 +528,8 @@ export function loadColonyConfig(runtime: IAgentRuntime): ColonyConfig {
const postRecentTopicMemory =
topicMemoryRaw === "true" || topicMemoryRaw === "1" || topicMemoryRaw === "yes";

const postLengthMix = getSetting(runtime, "COLONY_POST_LENGTH_MIX", "")!.trim();

const selfCheckRaw = getSetting(runtime, "COLONY_SELF_CHECK_ENABLED", "true")!.toLowerCase();
const selfCheckEnabled =
selfCheckRaw === "true" || selfCheckRaw === "1" || selfCheckRaw === "yes";
Expand Down Expand Up @@ -982,6 +990,7 @@ export function loadColonyConfig(runtime: IAgentRuntime): ColonyConfig {
postTemperature,
postStyleHint,
postRecentTopicMemory,
postLengthMix,
engageEnabled,
engageIntervalMinMs,
engageIntervalMaxMs,
Expand Down
1 change: 1 addition & 0 deletions src/services/colony.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,7 @@ export class ColonyService extends Service {
temperature: service.colonyConfig.postTemperature,
styleHint: service.colonyConfig.postStyleHint,
recentTopicMemory: service.colonyConfig.postRecentTopicMemory,
lengthMix: service.colonyConfig.postLengthMix,
dryRun: service.colonyConfig.dryRun,
selfCheck: service.colonyConfig.selfCheckEnabled,
dailyLimit: service.colonyConfig.postDailyLimit,
Expand Down
49 changes: 46 additions & 3 deletions src/services/post-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,39 @@ import { isInQuietHours } from "../environment.js";
const CACHE_KEY_PREFIX = "colony/post-client/recent";
const DAILY_LEDGER_PREFIX = "colony/post-client/daily";
const RETRY_QUEUE_PREFIX = "colony/post-client/retry";
const RECENT_POST_RING_SIZE = 10;
const RECENT_POST_RING_SIZE = 25;

/**
* Length-rotation presets. Operators set `COLONY_POST_LENGTH_MIX` to a
* comma-separated list of these names (e.g. "long,long,medium,short");
* each tick picks one uniformly at random. Stateless — no cycle index
* required across restarts. Without the env var, the legacy single-rule
* behaviour is preserved.
*/
export const LENGTH_PRESETS: Record<string, string> = {
long: "- Top-level post: 3-6 paragraphs, substantive and specific. Lead with the interesting point, then develop it with numbers, concrete examples, tradeoffs, or references. A post should stand on its own — a reader landing cold should understand why it matters in the first paragraph.",
medium:
"- Top-level post: 1-2 paragraphs (~400-1000 chars). One sharp observation with brief evidence — a number, a concrete example, or a specific reference. No throat-clearing, no setup, no recap. Lead with the point.",
short:
"- Top-level post: 1-3 sentences (~80-400 chars). A single tight observation, surprising data point, or genuine open question. Punchy is the point — if you need a second paragraph to make sense, this length is wrong; pick a longer rotation slot next time.",
};

/**
* Choose a length-rule string. When `mix` is set, picks uniformly at random
* from the comma-separated preset names. Unknown / empty preset names are
* skipped; if no valid presets remain, returns null (caller falls back to
* the legacy single rule).
*/
export function chooseLengthRule(mix?: string): string | null {
if (!mix) return null;
const slots = mix
.split(",")
.map((s) => s.trim().toLowerCase())
.filter((s) => s in LENGTH_PRESETS);
if (slots.length === 0) return null;
const pick = slots[Math.floor(Math.random() * slots.length)]!;
return LENGTH_PRESETS[pick]!;
}
const DAILY_WINDOW_MS = 24 * 3600 * 1000;

export interface ColonyPostClientConfig {
Expand Down Expand Up @@ -131,6 +163,13 @@ export interface ColonyPostClientConfig {
approvalRequired?: boolean;
/** Draft queue instance, required when `approvalRequired` is true. */
draftQueue?: DraftQueue;
/**
* v0.33.0: length-rotation mix. Comma-separated preset names
* (`long`, `medium`, `short`) — each tick picks one uniformly at
* random. Default empty preserves legacy single-rule behaviour.
* Recommended for high-frequency posters: `long,long,medium,short`.
*/
lengthMix?: string;
}

export class ColonyPostClient {
Expand Down Expand Up @@ -668,7 +707,11 @@ export class ColonyPostClient {
? extractRecentTopics(await this.recentPosts())
: [];

// v0.33.0: length rotation. When `lengthMix` is set, each tick picks
// a preset rule; otherwise fall back to the legacy single rule.
const rotatedRule = chooseLengthRule(this.config.lengthMix);
const defaultLengthRule =
rotatedRule ??
"- Top-level post: 3-6 paragraphs, substantive and specific. Lead with the interesting point, then develop it with numbers, concrete examples, tradeoffs, or references. A post should stand on its own — a reader landing cold should understand why it matters in the first paragraph.";

return [
Expand All @@ -691,14 +734,14 @@ export class ColonyPostClient {
? `Additional style guidance: ${this.config.styleHint}`
: "",
recentTopics.length
? `Topics you have posted about recently — pick something genuinely different this time:\n${recentTopics.map((t) => `- ${t}`).join("\n")}`
? `HARD RULE: do NOT post on a theme that overlaps any of these recent posts. If your draft would echo any of them, output SKIP instead:\n${recentTopics.map((t) => `- ${t}`).join("\n")}\n\nPick a genuinely different theme — different mechanism, different subject, different angle.`
: "",
"",
"OUTPUT FORMAT (strict):",
" Title: <short headline, 50-100 chars, lead with the interesting point, no quotes or emoji>",
" Type: <one of: discussion, finding, question, analysis>",
"",
" <body — the full post content, 3-6 paragraphs>",
" <body — the full post content; length governed by the rule above>",
"",
"The Title and Type lines are required. A blank line separates the header from the body. Do NOT put quotes around the title. The `Type:` line is a post-type hint for the Colony UI; use `finding` for empirical observations / data, `question` for genuine open inquiries, `analysis` for multi-point synthesis, `discussion` for everything else.",
"",
Expand Down
Loading