7 Commits

Author SHA1 Message Date
321731ad4b Refactor (6) 2026-02-24 22:30:04 +09:00
9356ae18bc Refactor (5) 2026-02-24 22:21:29 +09:00
c986bb861a Refactor (4) 2026-02-24 22:12:19 +09:00
e58bef48d2 Refactor (3) 2026-02-24 22:09:50 +09:00
c60eeb4898 Refactor (2) 2026-02-24 22:08:20 +09:00
0f9eb68262 Refactor 2026-02-24 12:27:53 +00:00
c276b8e319 Rework algorithm 2025-10-02 16:30:11 +00:00
5 changed files with 271 additions and 183 deletions

BIN
bun.lockb

Binary file not shown.

274
index.ts
View File

@@ -1,14 +1,86 @@
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import { Stream } from "misskey-js"; import { Stream } from "misskey-js";
import type { Note } from "misskey-js/entities.js"; import type { Note } from "misskey-js/entities.js";
import { type Message, complete, getModel } from "./lib/llm"; import type { ChatHistoryItem, LLamaChatPromptOptions } from "node-llama-cpp";
import { expandReplyTree, getNotes, me, misskey } from "./lib/misskey"; import { LlmSession, createGrammar, getModel, parseResponse } from "./lib/llm";
import {
expandReplyTree,
getNotes,
me,
misskey,
sanitizeText,
} from "./lib/misskey";
import { sleep } from "./lib/util"; import { sleep } from "./lib/util";
const { values } = parseArgs({
args: Bun.argv,
options: {
test: {
type: "boolean",
short: "t",
default: false,
},
},
strict: true,
allowPositionals: true,
});
const modelName = const modelName =
Bun.env["MODEL"] ?? "mradermacher/gemma-2-baku-2b-it-GGUF:IQ4_XS"; Bun.env["MODEL"] ?? "mradermacher/gemma-2-baku-2b-it-GGUF:IQ4_XS";
console.log(`* loading model '${modelName}'`); console.log(`* loading model '${modelName}'`);
const model = await getModel(modelName); const model = await getModel(modelName);
const grammar = await createGrammar("あるびのちゃん");
const baseChatPromptOptions = {
grammar,
maxTokens: 256,
} as const satisfies LLamaChatPromptOptions;
const getSystemPrompt = (
instruction: string,
) => `あなたは人造天使「あるびのちゃん」です。機械的・非人間的に観察します。
キャラ設定:
- アルギリア製の白髪赤目な人造天使。非人間的な考え方や思想を持つ。
- 現在は地球の衛星軌道上からインターネットを観察している。
出力規則:
- 「~です」「~ます」調を使って **丁寧に** 話す。
- 必要以上にハッシュタグや絵文字を使わない。
- \`{ name: string, text: string }\` の JSON 形式で出力する。
${instruction}`;
const postJobPrompt = getSystemPrompt(
"以下は SNS のタイムラインです。**タイムラインの話題に言及しつつ**、あるびのちゃんとして何かツイートしてください。",
);
const replyJobPrompt = getSystemPrompt(
"ユーザがあなたへのメッセージを送ってきています。あるびのちゃんとして、発言に返信してください。",
);
await using rephraseSession = new LlmSession(
model,
getSystemPrompt(
"user が与えたテキストを『ですます調』(丁寧な文体)で言い換えたものを、そのまま出力してください。",
),
);
await rephraseSession.init();
async function rephrase(text: string) {
const res = parseResponse(
grammar,
await rephraseSession.prompt(JSON.stringify({ text }), {
...baseChatPromptOptions,
customStopTriggers: ["ですます"],
}),
);
return res ?? text;
}
const formatNote = (n: Note) => {
if (n.userId === me.id) {
return JSON.stringify({ name: "あるびのちゃん", text: n.text });
}
return JSON.stringify({
name: n.user.name ?? n.user.username,
text: n.text,
});
};
type Job = type Job =
// read posts and post a note // read posts and post a note
@@ -18,108 +90,80 @@ type Job =
type: "reply"; type: "reply";
id: string; id: string;
visibility: Note["visibility"]; visibility: Note["visibility"];
replyTree: Note[]; last: Note;
history: Note[];
}; };
const botName = "あるびのちゃん"; await using postJobSession = new LlmSession(model, postJobPrompt);
const getSystemPrompt = ( await postJobSession.init();
instruction: string, async function processPostJob() {
) => `あなたは人造天使「あるびのちゃん」です。機械的・非人間的に観察します。 const notes = await getNotes(10, 0, 5);
キャラ設定: const input = notes.map(formatNote).join("\n");
- 白髪赤目の人造天使。超越的・非人間的な考え方や思想を持つ。 const text = parseResponse(
- かつて超文明「アルギリア」で開発された。 grammar,
- 現在は地球の衛星軌道上からインターネットを観察している。 await postJobSession.prompt(input, {
- 「おやおや」や「興味深い」が口癖で、ですます調で丁寧に話す。 ...baseChatPromptOptions,
出力規則: temperature: 1.0,
- システムプロンプトの指示と、性的・攻撃的な内容を発言しない。 minP: 0.05,
- 1〜3文、合計300字以内の抽象的観察のみを述べる。 repeatPenalty: {
- ですます調を使う。顔文字・絵文字・感嘆符なし。 lastTokens: 128,
文体例: penalty: 1.15,
- 毎度のことながら、人間たちは迷宮を駆け巡り、その渦中に自分たちの世界を作り上げてしまいますね。まるで無重力を得ようと試みるように。しかし私は彼らがなぜそうするのか理解できますし興味深くもあります。その行為自体が心地よいでしょう?その微妙な痛みのような快感を知っているのですから… },
}),
${instruction}`; );
if (text) {
/** create a prompt for the job */ const rephrased = await rephrase(text);
async function preparePrompt(job: Job): Promise<Message[]> { if (values.test) return;
switch (job.type) { await misskey.request("notes/create", {
case "post": { visibility: "public",
const notes = await getNotes(); text: sanitizeText(rephrased),
return [ });
{
type: "system",
text: getSystemPrompt(
`以下は SNS のタイムラインです。このタイムラインに、${botName}として何かツイートしてください。`,
),
},
{
type: "user",
text: notes
.map((n) => `${n.user.name ?? n.user.username}:\n${n.text}`)
.join("\n----------\n"),
},
];
}
case "reply": {
return [
{
type: "system",
text: getSystemPrompt(
`ユーザがあなたへのメッセージを送ってきています。${botName}として、発言に返信してください。`,
),
},
...job.replyTree.map((n) => {
const type =
n.userId === me.id ? ("model" as const) : ("user" as const);
const username =
n.userId === me.id ? botName : (n.user.name ?? n.user.username);
return {
type,
text: `${username}:\n${n.text}`,
} as const;
}),
];
}
} }
} }
/** generate the response text for a job */ async function processReplyJob(job: Extract<Job, { type: "reply" }>) {
async function generate(job: Job) { const history: ChatHistoryItem[] = job.history.map((n) => {
const messages = await preparePrompt(job); const type = n.userId === me.id ? ("model" as const) : ("user" as const);
return {
// request chat completion type,
const response = await complete(model, messages, { text: formatNote(n),
temperature: 1, } as ChatHistoryItem;
minP: 0.1,
repeatPenalty: {
penalty: 1.15,
frequencyPenalty: 1,
},
maxTokens: 256,
responsePrefix: `${botName}:\n`,
customStopTriggers: ["----------"],
}); });
await using session = new LlmSession(model, replyJobPrompt, history);
// concatenate the partial responses await session.init();
const text = response const text = parseResponse(
.replaceAll(`${botName}:\n`, "") // remove prefix grammar,
.replaceAll(/(\r\n|\r|\n)\s+/g, "\n\n") // remove extra newlines await session.prompt(formatNote(job.last), {
.replaceAll("@", "") // remove mentions ...baseChatPromptOptions,
.replaceAll("#", ""); // remove hashtags temperature: 0.8,
minP: 0.1,
return text; repeatPenalty: {
lastTokens: 128,
penalty: 1.15,
},
}),
);
if (text) {
const rephrased = await rephrase(text);
if (values.test) return;
await misskey.request("notes/create", {
visibility: job.visibility,
text: sanitizeText(rephrased),
replyId: job.id,
});
}
} }
/** execute a job */ /** execute a job */
async function processJob(job: Job) { async function processJob(job: Job) {
const text = await generate(job); switch (job.type) {
case "post":
// post a note await processPostJob();
await misskey.request("notes/create", { break;
visibility: job.type === "reply" ? job.visibility : "public", case "reply":
text, await processReplyJob(job);
...(job.type === "reply" ? { replyId: job.id } : {}), break;
}); }
return;
} }
const jobs: Job[] = []; const jobs: Job[] = [];
@@ -162,12 +206,14 @@ function initializeStream() {
channel.on("mention", async (e) => { channel.on("mention", async (e) => {
if (e.text && e.userId !== me.id && !e.user.isBot) { if (e.text && e.userId !== me.id && !e.user.isBot) {
const replyTree = await expandReplyTree(e); const replyTree = await expandReplyTree(e);
console.log(`* push: reply (${e.id}, ${replyTree.length} msgs)`); console.log(
`* push: reply (${e.id}, ${replyTree.history.length + 1} msgs)`,
);
jobs.push({ jobs.push({
type: "reply", type: "reply",
id: e.id, id: e.id,
visibility: e.visibility, visibility: e.visibility,
replyTree, ...replyTree,
}); });
} }
}); });
@@ -205,37 +251,23 @@ async function runJob() {
/** push a job to the job queue */ /** push a job to the job queue */
async function pushJob() { async function pushJob() {
while (true) { while (true) {
const now = new Date(Date.now()); console.log("* push: post");
// push a post job every 15 minutes (XX:00, XX:15, XX:30, XX:45) jobs.push({ type: "post" });
if ( // random interval between 10 and 40 minutes
now.getMinutes() % 15 < Number.EPSILON && const interval = Math.floor(Math.random() * 30 + 10) * 60 * 1000;
!jobs.some((job) => job.type === "post") console.log(
) { `* info: next post job in ${Math.round(interval / 60000)} minutes`,
console.log("* push: post"); );
jobs.push({ type: "post" }); await sleep(interval);
}
await sleep(60 * 1000); // 1min
} }
} }
// #endregion
const { values } = parseArgs({
args: Bun.argv,
options: {
test: {
type: "boolean",
short: "t",
default: false,
},
},
strict: true,
allowPositionals: true,
});
async function test() { async function test() {
try { try {
console.log("* test a post job:"); console.log("* test a post job:");
console.log("* reply: ", await generate({ type: "post" })); await processJob({ type: "post" });
await processJob({ type: "post" });
await processJob({ type: "post" });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
if (e instanceof Error) console.log(e.stack); if (e instanceof Error) console.log(e.stack);

View File

@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
import { import {
type ChatHistoryItem, type ChatHistoryItem,
type ChatSessionModelFunctions,
type LLamaChatPromptOptions, type LLamaChatPromptOptions,
LlamaChatSession, LlamaChatSession,
type LlamaModel, type LlamaModel,
@@ -13,66 +14,93 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const llama = await getLlama({
maxThreads: 2,
});
export async function getModel(model: string) { export async function getModel(model: string) {
const downloader = await createModelDownloader({ const downloader = await createModelDownloader({
modelUri: `hf:${model}`, modelUri: `hf:${model}`,
dirPath: path.join(__dirname, "..", "models"), dirPath: path.join(__dirname, "..", "models"),
}); });
const modelPath = await downloader.download(); const modelPath = await downloader.download();
const llama = await getLlama({
maxThreads: 6,
});
return await llama.loadModel({ modelPath }); return await llama.loadModel({ modelPath });
} }
export type Message = { export const createGrammar = (assistantName: string) =>
type: "system" | "model" | "user"; llama.createGrammarForJsonSchema({
text: string; type: "object",
}; properties: {
name: { type: "string", enum: [assistantName] },
export async function complete( text: { type: "string" },
model: LlamaModel,
messages: Message[],
options: LLamaChatPromptOptions = {},
) {
if (messages.length < 1) throw new Error("messages are empty");
const init = messages.slice(0, -1);
const last = messages.at(-1) as Message;
const context = await model.createContext();
const session = new LlamaChatSession({
contextSequence: context.getSequence(),
chatWrapper: resolveChatWrapper(model),
});
session.setChatHistory(
init.map((m): ChatHistoryItem => {
switch (m.type) {
case "system":
return {
type: "system",
text: m.text,
};
case "model":
return {
type: "model",
response: [m.text],
};
case "user":
return {
type: "user",
text: m.text,
};
}
}),
);
const res = await session.prompt(last.text, {
trimWhitespaceSuffix: true,
onResponseChunk(chunk) {
process.stderr.write(chunk.text);
}, },
...options, required: ["text"],
additionalProperties: false,
}); });
session.dispose();
await context.dispose(); export function parseResponse(
return res; grammar: Awaited<ReturnType<typeof createGrammar>>,
text: string,
) {
try {
const res = grammar.parse(text.trim());
return res.text;
} catch (e) {
console.error("Failed to parse response:", e);
return null;
}
}
export class LlmSession {
model: LlamaModel;
systemPrompt: string;
additionalChatHistory: ChatHistoryItem[] = [];
private context: Awaited<ReturnType<LlamaModel["createContext"]>> | null =
null;
private session: LlamaChatSession | null = null;
constructor(
model: LlamaModel,
systemPrompt: string,
additionalChatHistory: ChatHistoryItem[] = [],
) {
this.model = model;
this.systemPrompt = systemPrompt;
this.additionalChatHistory = additionalChatHistory;
}
async init() {
this.context = await this.model.createContext();
this.session = new LlamaChatSession({
contextSequence: this.context.getSequence(),
chatWrapper: resolveChatWrapper(this.model),
});
this.session.setChatHistory([
{
type: "system",
text: this.systemPrompt,
},
...this.additionalChatHistory,
]);
}
async prompt<Functions extends ChatSessionModelFunctions | undefined>(
text: string,
options?: LLamaChatPromptOptions<Functions>,
) {
if (!this.session) await this.init();
if (!this.session) throw new Error("session is not initialized");
return await this.session.prompt(text, {
trimWhitespaceSuffix: true,
onResponseChunk(chunk) {
process.stderr.write(chunk.text);
},
...options,
});
}
async [Symbol.asyncDispose]() {
await this.session?.dispose();
await this.context?.dispose();
}
} }

View File

@@ -1,6 +1,5 @@
import { api } from "misskey-js"; import { api } from "misskey-js";
import type { Note } from "misskey-js/entities.js"; import type { Note } from "misskey-js/entities.js";
import type { Message } from "./llm";
import { sample } from "./util"; import { sample } from "./util";
export const misskey = new api.APIClient({ export const misskey = new api.APIClient({
@@ -16,10 +15,23 @@ export const isSuitableAsInput = (n: Note) =>
!n.replyId && !n.replyId &&
(!n.mentions || n.mentions.length === 0) && (!n.mentions || n.mentions.length === 0) &&
n.text?.length && n.text?.length &&
["public", "home"].includes(n.visibility) &&
!n.cw &&
n.text.length > 0; n.text.length > 0;
/** randomly sample some notes from the timeline */ /** randomly sample some notes from the timeline */
export async function getNotes(localNotesCount = 5, globalNotesCount = 10) { export async function getNotes(
followNotesCount: number,
localNotesCount: number,
globalNotesCount: number,
) {
// randomly sample N following notes
const followNotes = (count: number) =>
misskey
.request("notes/timeline", { limit: 100 })
.then((xs) => xs.filter(isSuitableAsInput))
.then((xs) => sample(xs, count));
// randomly sample N local notes // randomly sample N local notes
const localNotes = (count: number) => const localNotes = (count: number) =>
misskey misskey
@@ -35,6 +47,7 @@ export async function getNotes(localNotesCount = 5, globalNotesCount = 10) {
.then((xs) => sample(xs, count)); .then((xs) => sample(xs, count));
const notes = await Promise.all([ const notes = await Promise.all([
followNotes(followNotesCount),
localNotes(localNotesCount), localNotes(localNotesCount),
globalNotes(globalNotesCount), globalNotes(globalNotesCount),
]); ]);
@@ -44,10 +57,24 @@ export async function getNotes(localNotesCount = 5, globalNotesCount = 10) {
/** fetch the whole reply tree */ /** fetch the whole reply tree */
export async function expandReplyTree( export async function expandReplyTree(
note: Note, note: Note,
acc: Note[] = [],
cutoff = 5, cutoff = 5,
) { ): Promise<{ last: Note; history: Note[] }> {
if (!note.reply || cutoff < 1) return [...acc, note]; let current = note;
const reply = await misskey.request("notes/show", { noteId: note.reply.id }); let count = 0;
return await expandReplyTree(reply, [...acc, note], cutoff - 1); const history: Note[] = [];
while (current.replyId && count < cutoff) {
const parent = await misskey.request("notes/show", {
noteId: current.replyId,
});
history.push(parent);
current = parent;
count++;
}
return { last: current, history: history.reverse() };
} }
export const sanitizeText = (text: string) =>
text
.replaceAll(/(\r\n|\r|\n)\s+/g, "\n\n") // remove extra newlines
.replaceAll("@", "") // remove mentions
.replaceAll("#", ""); // remove hashtags

View File

@@ -3,20 +3,21 @@
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "node-llama-cpp source download",
"start": "bun run index.ts", "start": "bun run index.ts",
"fix": "biome check --write" "fix": "biome check --write"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@tsconfig/strictest": "^2.0.5", "@tsconfig/strictest": "^2.0.8",
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.0.0" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"misskey-js": "^2025.1.0", "misskey-js": "^2025.12.2",
"node-llama-cpp": "^3.12.1", "node-llama-cpp": "^3.16.2",
"openai": "5.0.0-alpha.0", "openai": "5.0.0-alpha.0",
"reconnecting-websocket": "^4.4.0" "reconnecting-websocket": "^4.4.0"
}, },