Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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 platform/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,18 +221,23 @@ export type {
WorkspaceSourceRepoSyncResult,
} from "./workspace-source-repo";
export {
addDiscordSource,
addObsidianSource,
DEFAULT_DISCORD_MAX_MESSAGES,
DEFAULT_OBSIDIAN_EXCLUDE_GLOBS,
getAgentsDir,
getSourcesConfigPath,
loadSourcesConfig,
markSourceIndexed,
parseDiscordSettings,
removeSource,
saveSourcesConfig,
} from "./sources-config";
export type {
AddDiscordSourceInput,
AddObsidianSourceInput,
AddSourceResult,
DiscordSourceSettings,
RemoveSourceResult,
SignetSourceEntry,
SignetSourceKind,
Expand Down
120 changes: 120 additions & 0 deletions platform/core/src/sources-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import {
DEFAULT_OBSIDIAN_EXCLUDE_GLOBS,
addDiscordSource,
addObsidianSource,
getSourcesConfigPath,
loadSourcesConfig,
markSourceIndexed,
parseDiscordSettings,
removeSource,
} from "./sources-config";

Expand Down Expand Up @@ -152,4 +154,122 @@ describe("sources-config", () => {
if (removed.ok === true) throw new Error("expected removeSource to fail");
expect(removed.error).toContain("not found");
});

describe("discord source", () => {
it("adds a Discord source with guild IDs and token ref", () => {
const agentsDir = tmp();
const result = addDiscordSource(
{
guildIds: ["123456789012345678"],
tokenRef: "DISCORD_BOT_TOKEN",
name: "My Discord Server",
now: "2026-01-01T00:00:00.000Z",
},
agentsDir,
);

expect(result.ok).toBe(true);
if (result.ok === false) throw new Error(result.error);
expect(result.created).toBe(true);
expect(result.source.kind).toBe("discord");
expect(result.source.mode).toBe("read-only");
expect(result.source.name).toBe("My Discord Server");
expect(result.source.root).toBe("");
expect(result.source.settings).toBeDefined();
expect((result.source.settings as { guildIds: string[] }).guildIds).toEqual(["123456789012345678"]);
expect((result.source.settings as { tokenRef: string }).tokenRef).toBe("DISCORD_BOT_TOKEN");
});

it("rejects missing guild IDs", () => {
const agentsDir = tmp();
const result = addDiscordSource(
{ guildIds: [], tokenRef: "TOKEN" },
agentsDir,
);
expect(result.ok).toBe(false);
if (result.ok === true) throw new Error("expected failure");
expect(result.error).toContain("guild ID");
});

it("rejects missing token ref", () => {
const agentsDir = tmp();
const result = addDiscordSource(
{ guildIds: ["123456789012345678"], tokenRef: "" },
agentsDir,
);
expect(result.ok).toBe(false);
if (result.ok === true) throw new Error("expected failure");
expect(result.error).toContain("token");
});

it("rejects invalid guild ID format", () => {
const agentsDir = tmp();
const result = addDiscordSource(
{ guildIds: ["not-a-number"], tokenRef: "TOKEN" },
agentsDir,
);
expect(result.ok).toBe(false);
if (result.ok === true) throw new Error("expected failure");
expect(result.error).toContain("Invalid Discord guild ID");
});

it("deduplicates by sorted guild IDs", () => {
const agentsDir = tmp();
const first = addDiscordSource(
{ guildIds: ["111111111111111111"], tokenRef: "TOKEN", name: "Server A", now: "2026-01-01T00:00:00.000Z" },
agentsDir,
);
const second = addDiscordSource(
{ guildIds: ["111111111111111111"], tokenRef: "TOKEN2", name: "Server B", now: "2026-01-02T00:00:00.000Z" },
agentsDir,
);

expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
if (second.ok === false) throw new Error(second.error);
expect(second.created).toBe(false);
expect(second.source.name).toBe("Server B");
expect(loadSourcesConfig(agentsDir).sources).toHaveLength(1);
});

it("parseDiscordSettings returns defaults for empty input", () => {
const settings = parseDiscordSettings(undefined);
expect(settings.guildIds).toEqual([]);
expect(settings.tokenRef).toBe("");
expect(settings.includeThreads).toBe(true);
});

it("parseDiscordSettings preserves valid fields", () => {
const settings = parseDiscordSettings({
guildIds: ["123"],
tokenRef: "MY_TOKEN",
channelFilter: ["general"],
maxMessagesPerChannel: 500,
includeThreads: false,
since: "2026-01-01",
});
expect(settings.guildIds).toEqual(["123"]);
expect(settings.tokenRef).toBe("MY_TOKEN");
expect(settings.channelFilter).toEqual(["general"]);
expect(settings.maxMessagesPerChannel).toBe(500);
expect(settings.includeThreads).toBe(false);
expect(settings.since).toBe("2026-01-01");
});

it("removes a Discord source by id", () => {
const agentsDir = tmp();
const added = addDiscordSource(
{ guildIds: ["123456789012345678"], tokenRef: "TOKEN", now: "2026-01-01T00:00:00.000Z" },
agentsDir,
);
expect(added.ok).toBe(true);
if (added.ok === false) throw new Error(added.error);

const removed = removeSource(added.source.id, agentsDir);
expect(removed.ok).toBe(true);
if (removed.ok === false) throw new Error(removed.error);
expect(removed.source.id).toBe(added.source.id);
expect(loadSourcesConfig(agentsDir).sources).toEqual([]);
});
});
});
122 changes: 119 additions & 3 deletions platform/core/src/sources-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writ
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";

export type SignetSourceKind = "obsidian";
export type SignetSourceKind = "obsidian" | "discord";
export type SignetSourceMode = "read-only";

export interface SignetSourceEntry {
Expand All @@ -17,6 +17,7 @@ export interface SignetSourceEntry {
readonly updatedAt: string;
readonly lastIndexedAt?: string;
readonly excludeGlobs?: readonly string[];
readonly settings?: Readonly<Record<string, unknown>>;
}

export const DEFAULT_OBSIDIAN_EXCLUDE_GLOBS = [
Expand All @@ -39,6 +40,28 @@ export interface AddObsidianSourceInput {
readonly now?: string;
}

export interface DiscordSourceSettings {
readonly guildIds: readonly string[];
readonly tokenRef: string;
readonly channelFilter?: readonly string[];
readonly maxMessagesPerChannel?: number;
readonly includeThreads?: boolean;
readonly since?: string;
}

export const DEFAULT_DISCORD_MAX_MESSAGES = 1000;

export interface AddDiscordSourceInput {
readonly guildIds: readonly string[];
readonly tokenRef: string;
readonly name?: string;
readonly channelFilter?: readonly string[];
readonly maxMessagesPerChannel?: number;
readonly includeThreads?: boolean;
readonly since?: string;
readonly now?: string;
}

export type AddSourceResult =
| { readonly ok: true; readonly source: SignetSourceEntry; readonly created: boolean }
| { readonly ok: false; readonly error: string };
Expand Down Expand Up @@ -105,6 +128,98 @@ export function addObsidianSource(input: AddObsidianSourceInput, agentsDir = get
return withSourcesConfigLock(agentsDir, () => addObsidianSourceUnlocked(input, agentsDir));
}

export function addDiscordSource(input: AddDiscordSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
return withSourcesConfigLock(agentsDir, () => addDiscordSourceUnlocked(input, agentsDir));
}

function addDiscordSourceUnlocked(input: AddDiscordSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
try {
return addDiscordSourceChecked(input, agentsDir);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return { ok: false, error: detail };
}
}

function addDiscordSourceChecked(input: AddDiscordSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
if (!input.guildIds || input.guildIds.length === 0) {
return { ok: false, error: "At least one Discord guild ID is required" };
}
const trimmedTokenRef = input.tokenRef?.trim();
if (!trimmedTokenRef) return { ok: false, error: "Discord bot token reference (tokenRef) is required" };
for (const id of input.guildIds) {
if (!/^\d{17,20}$/.test(id.trim())) {
return { ok: false, error: `Invalid Discord guild ID: ${id}` };
}
}

const guildIds = input.guildIds.map((id) => id.trim());
const key = guildIds.slice().sort().join(",");
const now = input.now ?? new Date().toISOString();
const cfg = loadSourcesConfigForWrite(agentsDir);
const sourceId = `discord:${createHash("sha256").update(key).digest("hex").slice(0, 16)}`;
const existing = cfg.sources.find((source) => source.id === sourceId);

if (existing) {
const updated = {
...existing,
name: cleanName(input.name) ?? existing.name,
enabled: true,
settings: buildDiscordSettings(input),
updatedAt: now,
};
saveSourcesConfig(
{
version: SOURCES_CONFIG_VERSION,
sources: cfg.sources.map((source) => (source.id === existing.id ? updated : source)),
},
agentsDir,
);
return { ok: true, source: updated, created: false };
}

const source: SignetSourceEntry = {
id: sourceId,
kind: "discord",
name: cleanName(input.name) ?? "Discord Server",
root: "",
enabled: true,
mode: "read-only",
createdAt: now,
updatedAt: now,
settings: buildDiscordSettings(input),
};
saveSourcesConfig({ version: SOURCES_CONFIG_VERSION, sources: [...cfg.sources, source] }, agentsDir);
return { ok: true, source, created: true };
}

function buildDiscordSettings(input: AddDiscordSourceInput): Record<string, unknown> {
return {
guildIds: input.guildIds.map((id) => id.trim()),
tokenRef: input.tokenRef.trim(),
...(input.channelFilter && input.channelFilter.length > 0 ? { channelFilter: input.channelFilter } : {}),
maxMessagesPerChannel: input.maxMessagesPerChannel ?? DEFAULT_DISCORD_MAX_MESSAGES,
includeThreads: input.includeThreads ?? true,
...(input.since ? { since: input.since } : {}),
};
}

export function parseDiscordSettings(raw?: Readonly<Record<string, unknown>>): DiscordSourceSettings {
if (!raw) {
return { guildIds: [], tokenRef: "", includeThreads: true };
}
const guildIds = Array.isArray(raw.guildIds) ? raw.guildIds.filter((id): id is string => typeof id === "string") : [];
const tokenRef = typeof raw.tokenRef === "string" ? raw.tokenRef : "";
const channelFilter = Array.isArray(raw.channelFilter)
? raw.channelFilter.filter((id): id is string => typeof id === "string")
: undefined;
const maxMessagesPerChannel =
typeof raw.maxMessagesPerChannel === "number" ? raw.maxMessagesPerChannel : DEFAULT_DISCORD_MAX_MESSAGES;
const includeThreads = typeof raw.includeThreads === "boolean" ? raw.includeThreads : true;
const since = typeof raw.since === "string" ? raw.since : undefined;
return { guildIds, tokenRef, channelFilter, maxMessagesPerChannel, includeThreads, since };
}

function addObsidianSourceUnlocked(input: AddObsidianSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
try {
return addObsidianSourceChecked(input, agentsDir);
Expand Down Expand Up @@ -272,7 +387,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function isSourceEntry(value: unknown): value is SignetSourceEntry {
return (
isRecord(value) &&
value.kind === "obsidian" &&
(value.kind === "obsidian" || value.kind === "discord") &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.root === "string" &&
Expand All @@ -282,6 +397,7 @@ function isSourceEntry(value: unknown): value is SignetSourceEntry {
typeof value.updatedAt === "string" &&
(value.lastIndexedAt === undefined || typeof value.lastIndexedAt === "string") &&
(value.excludeGlobs === undefined ||
(Array.isArray(value.excludeGlobs) && value.excludeGlobs.every((entry) => typeof entry === "string")))
(Array.isArray(value.excludeGlobs) && value.excludeGlobs.every((entry) => typeof entry === "string"))) &&
(value.settings === undefined || isRecord(value.settings))
);
}
25 changes: 25 additions & 0 deletions platform/daemon/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { logger } from "./logger";
import { type ResolvedMemoryConfig, loadMemoryConfig } from "./memory-config";
import { registerGlobalMiddleware } from "./middleware";
import { type NativeMemoryBridgeHandle, startNativeMemoryBridge } from "./native-memory-sources";
import { syncDiscordSource, purgeDiscordSource } from "./discord-source-bridge";
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
import { DEFAULT_RETENTION, ensureRetentionWorker, setDreamingWorker, startPipeline, stopPipeline } from "./pipeline";
import { type DreamingWorkerHandle, startDreamingWorker } from "./pipeline/dreaming-worker";
import type { WorkerInit } from "./pipeline/extraction-thread-protocol";
Expand Down Expand Up @@ -1610,6 +1611,30 @@ async function main() {
});
}

for (const source of loadSourcesConfig(AGENTS_DIR).sources) {
if (!source.enabled || source.kind !== "discord") continue;
const startupEmbedCfg = loadMemoryConfig(AGENTS_DIR).embedding;
syncDiscordSource(source, {
agentId: resolveDaemonAgentId(),
embeddingConfig: startupEmbedCfg,
fetchEmbedding,
agentsDir: AGENTS_DIR,
})
.then((indexed) => {
logger.info("discord-source", "Discord source startup sync complete", {
sourceId: source.id,
indexed,
});
markSourceIndexed(source.id, undefined, AGENTS_DIR);
})
.catch((e) => {
logger.error("discord-source", "Discord source startup sync failed", undefined, {
sourceId: source.id,
error: e instanceof Error ? e.message : String(e),
});
});
}

const startupCfg = loadMemoryConfig(AGENTS_DIR);
if (startupCfg.embedding.provider !== "none") {
checkEmbeddingProvider(startupCfg.embedding)
Expand Down
Loading
Loading