Merge branch 'clawdbot:main' into main

This commit is contained in:
kastrah 2026-01-17 21:51:58 +01:00 committed by GitHub
commit 4ff0b1e443
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 368 additions and 35 deletions

View File

@ -2,7 +2,13 @@
Docs: https://docs.clawd.bot
## 2026.1.17 (Unreleased)
## 2026.1.17-2 (Unreleased)
### Changes
### Fixes
## 2026.1.17-1
### Changes
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
@ -10,14 +16,17 @@ Docs: https://docs.clawd.bot
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
- CLI: surface update availability in `clawdbot status`.
- CLI: add `clawdbot memory status --deep/--index` probes.
- CLI: add playful update completion quips.
### Fixes
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
## 2026.1.16-2

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.17",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.17",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.17",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {

View File

@ -1,5 +1,10 @@
# Changelog
## 2026.1.17-1
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.17",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {

View File

@ -1,5 +1,10 @@
# Changelog
## 2026.1.17-1
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.17",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {

View File

@ -1,5 +1,10 @@
# Changelog
## 2026.1.17-1
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "2026.1.17",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {

View File

@ -1,5 +1,10 @@
# Changelog
## 2026.1.17-1
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
"version": "2026.1.17",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.17",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.17",
"version": "2026.1.17-1",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",

View File

@ -167,7 +167,9 @@ describe("memory cli", () => {
registerMemoryCli(program);
await program.parseAsync(["memory", "status", "--index"], { from: "user" });
expect(sync).toHaveBeenCalledWith({ reason: "cli" });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", progress: expect.any(Function) }),
);
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
});

View File

@ -2,6 +2,7 @@ import type { Command } from "commander";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig } from "../config/config.js";
import { withProgress, withProgressTotals } from "./progress.js";
import { getMemorySearchManager } from "../memory/index.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
@ -46,18 +47,44 @@ export function registerMemoryCli(program: Command) {
return;
}
try {
await manager.probeVectorAvailability();
const deep = Boolean(opts.deep || opts.index);
const embeddingProbe = deep ? await manager.probeEmbeddingAvailability() : undefined;
let embeddingProbe: Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>> | undefined;
let indexError: string | undefined;
if (opts.index) {
try {
await manager.sync({ reason: "cli" });
} catch (err) {
indexError = err instanceof Error ? err.message : String(err);
defaultRuntime.error(`Memory index failed: ${indexError}`);
process.exitCode = 1;
if (deep) {
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
progress.setLabel("Probing vector…");
await manager.probeVectorAvailability();
progress.tick();
progress.setLabel("Probing embeddings…");
embeddingProbe = await manager.probeEmbeddingAvailability();
progress.tick();
});
if (opts.index) {
await withProgressTotals(
{ label: "Indexing memory…", total: 0 },
async (update, progress) => {
try {
await manager.sync({
reason: "cli",
progress: (syncUpdate) => {
update({
completed: syncUpdate.completed,
total: syncUpdate.total,
label: syncUpdate.label,
});
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
},
});
} catch (err) {
indexError = err instanceof Error ? err.message : String(err);
defaultRuntime.error(`Memory index failed: ${indexError}`);
process.exitCode = 1;
}
},
);
}
} else {
await manager.probeVectorAvailability();
}
const status = manager.status();
if (opts.json) {
@ -76,6 +103,10 @@ export function registerMemoryCli(program: Command) {
);
return;
}
if (opts.index) {
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
defaultRuntime.log(line);
}
const rich = isRich();
const heading = (text: string) => colorize(rich, theme.heading, text);
const muted = (text: string) => colorize(rich, theme.muted, text);

View File

@ -72,7 +72,7 @@ export async function maybeInstallDaemon(params: {
}
if (shouldInstall) {
let installError: unknown | null = null;
let installError: string | null = null;
await withProgress(
{ label: "Gateway daemon", indeterminate: true, delayMs: 0 },
async (progress) => {
@ -128,13 +128,13 @@ export async function maybeInstallDaemon(params: {
});
progress.setLabel("Gateway daemon installed.");
} catch (err) {
installError = err;
installError = err instanceof Error ? err.message : String(err);
progress.setLabel("Gateway daemon install failed.");
}
},
);
if (installError) {
note(`Gateway daemon install failed: ${String(installError)}`, "Gateway");
note("Gateway daemon install failed: " + installError, "Gateway");
if (process.platform === "win32") {
note(
"Tip: rerun from an elevated PowerShell (Start → type PowerShell → right-click → Run as administrator) or skip daemon install.",

View File

@ -29,12 +29,15 @@ describe("gateway ws log helpers", () => {
test("summarizeAgentEventForWsLog extracts useful fields", () => {
const summary = summarizeAgentEventForWsLog({
runId: "12345678-1234-1234-1234-123456789abc",
sessionKey: "agent:main:main",
stream: "assistant",
seq: 2,
data: { text: "hello world", mediaUrls: ["a", "b"] },
});
expect(summary).toMatchObject({
agent: "main",
run: "12345678…9abc",
session: "main",
stream: "assistant",
aseq: 2,
text: "hello world",

View File

@ -1,5 +1,6 @@
import chalk from "chalk";
import { isVerbose } from "../globals.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { createSubsystemLogger, shouldLogSubsystemToConsole } from "../logging.js";
import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js";
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
@ -88,11 +89,21 @@ export function summarizeAgentEventForWsLog(payload: unknown): Record<string, un
const runId = typeof rec.runId === "string" ? rec.runId : undefined;
const stream = typeof rec.stream === "string" ? rec.stream : undefined;
const seq = typeof rec.seq === "number" ? rec.seq : undefined;
const sessionKey = typeof rec.sessionKey === "string" ? rec.sessionKey : undefined;
const data =
rec.data && typeof rec.data === "object" ? (rec.data as Record<string, unknown>) : undefined;
const extra: Record<string, unknown> = {};
if (runId) extra.run = shortId(runId);
if (sessionKey) {
const parsed = parseAgentSessionKey(sessionKey);
if (parsed) {
extra.agent = parsed.agentId;
extra.session = parsed.rest;
} else {
extra.session = sessionKey;
}
}
if (stream) extra.stream = stream;
if (seq !== undefined) extra.aseq = seq;

View File

@ -51,8 +51,14 @@ export function resetAgentRunContextForTest() {
export function emitAgentEvent(event: Omit<AgentEventPayload, "seq" | "ts">) {
const nextSeq = (seqByRun.get(event.runId) ?? 0) + 1;
seqByRun.set(event.runId, nextSeq);
const context = runContextById.get(event.runId);
const sessionKey =
typeof event.sessionKey === "string" && event.sessionKey.trim()
? event.sessionKey
: context?.sessionKey;
const enriched: AgentEventPayload = {
...event,
sessionKey,
seq: nextSeq,
ts: Date.now(),
};

View File

@ -92,6 +92,17 @@ describe("enableConsoleCapture", () => {
vi.useRealTimers();
});
it("suppresses discord EventQueue slow listener duplicates", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const warn = vi.fn();
console.warn = warn;
enableConsoleCapture();
console.warn(
"[EventQueue] Slow listener detected: DiscordMessageListener took 12.3 seconds for event MESSAGE_CREATE",
);
expect(warn).not.toHaveBeenCalled();
});
it("does not double-prefix timestamps", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const warn = vi.fn();

View File

@ -90,7 +90,16 @@ const SUPPRESSED_CONSOLE_PREFIXES = [
function shouldSuppressConsoleMessage(message: string): boolean {
if (isVerbose()) return false;
return SUPPRESSED_CONSOLE_PREFIXES.some((prefix) => message.startsWith(prefix));
if (SUPPRESSED_CONSOLE_PREFIXES.some((prefix) => message.startsWith(prefix))) {
return true;
}
if (
message.startsWith("[EventQueue] Slow listener detected") &&
message.includes("DiscordMessageListener")
) {
return true;
}
return false;
}
function isEpipeError(err: unknown): boolean {

View File

@ -148,7 +148,15 @@ function formatConsoleLine(opts: {
? color.gray
: color.cyan;
const displayMessage = stripRedundantSubsystemPrefixForConsole(opts.message, displaySubsystem);
const time = opts.style === "pretty" ? color.gray(new Date().toISOString().slice(11, 19)) : "";
const time = (() => {
if (opts.style === "pretty") {
return color.gray(new Date().toISOString().slice(11, 19));
}
if (loggingState.consoleTimestampPrefix) {
return color.gray(new Date().toISOString());
}
return "";
})();
const prefixToken = prefixColor(prefix);
const head = [time, prefixToken].filter(Boolean).join(" ");
return `${head} ${levelColor(displayMessage)}`;

View File

@ -46,14 +46,18 @@ async function main() {
{ setGatewayWsLogStyle },
{ setVerbose },
{ defaultRuntime },
{ enableConsoleCapture, setConsoleTimestampPrefix },
] = await Promise.all([
import("../config/config.js"),
import("../gateway/server.js"),
import("../gateway/ws-logging.js"),
import("../globals.js"),
import("../runtime.js"),
import("../logging.js"),
]);
enableConsoleCapture();
setConsoleTimestampPrefix(true);
setVerbose(hasFlag(args, "--verbose"));
const wsLogRaw = (hasFlag(args, "--compact") ? "compact" : argValue(args, "--ws-log")) as

View File

@ -109,4 +109,44 @@ describe("memory embedding batches", () => {
expect(embedBatch.mock.calls.length).toBe(1);
});
it("reports sync progress totals", async () => {
const line = "c".repeat(120);
const content = Array.from({ length: 20 }, () => line).join("\n");
await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-05.md"), content);
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexPath },
chunking: { tokens: 200, overlap: 0 },
sync: { watch: false, onSessionStart: false, onSearch: false },
query: { minScore: 0 },
},
},
list: [{ id: "main", default: true }],
},
};
const result = await getMemorySearchManager({ cfg, agentId: "main" });
expect(result.manager).not.toBeNull();
if (!result.manager) throw new Error("manager missing");
manager = result.manager;
const updates: Array<{ completed: number; total: number; label?: string }> = [];
await manager.sync({
force: true,
progress: (update) => {
updates.push(update);
},
});
expect(updates.length).toBeGreaterThan(0);
const last = updates[updates.length - 1];
expect(last?.total).toBeGreaterThan(0);
expect(last?.completed).toBe(last?.total);
});
});

View File

@ -60,12 +60,24 @@ type SessionFileEntry = {
content: string;
};
type MemorySyncProgressUpdate = {
completed: number;
total: number;
label?: string;
};
type MemorySyncProgressState = {
completed: number;
total: number;
report: (update: MemorySyncProgressUpdate) => void;
};
const META_KEY = "memory_index_meta_v1";
const SNIPPET_MAX_CHARS = 700;
const VECTOR_TABLE = "chunks_vec";
const SESSION_DIRTY_DEBOUNCE_MS = 5000;
const EMBEDDING_BATCH_MAX_TOKENS = 8000;
const EMBEDDING_APPROX_CHARS_PER_TOKEN = 2;
const EMBEDDING_APPROX_CHARS_PER_TOKEN = 1;
const log = createSubsystemLogger("memory");
@ -258,7 +270,11 @@ export class MemoryIndexManager {
}));
}
async sync(params?: { reason?: string; force?: boolean }): Promise<void> {
async sync(params?: {
reason?: string;
force?: boolean;
progress?: (update: MemorySyncProgressUpdate) => void;
}): Promise<void> {
if (this.syncing) return this.syncing;
this.syncing = this.runSync(params).finally(() => {
this.syncing = null;
@ -650,21 +666,46 @@ export class MemoryIndexManager {
return this.sessionsDirty || needsFullReindex;
}
private async syncMemoryFiles(params: { needsFullReindex: boolean }) {
private async syncMemoryFiles(params: {
needsFullReindex: boolean;
progress?: MemorySyncProgressState;
}) {
const files = await listMemoryFiles(this.workspaceDir);
const fileEntries = await Promise.all(
files.map(async (file) => buildFileEntry(file, this.workspaceDir)),
);
const activePaths = new Set(fileEntries.map((entry) => entry.path));
if (params.progress) {
params.progress.total += fileEntries.length;
params.progress.report({
completed: params.progress.completed,
total: params.progress.total,
label: "Indexing memory files…",
});
}
for (const entry of fileEntries) {
const record = this.db
.prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`)
.get(entry.path, "memory") as { hash: string } | undefined;
if (!params.needsFullReindex && record?.hash === entry.hash) {
if (params.progress) {
params.progress.completed += 1;
params.progress.report({
completed: params.progress.completed,
total: params.progress.total,
});
}
continue;
}
await this.indexFile(entry, { source: "memory" });
if (params.progress) {
params.progress.completed += 1;
params.progress.report({
completed: params.progress.completed,
total: params.progress.total,
});
}
}
const staleRows = this.db
@ -677,22 +718,65 @@ export class MemoryIndexManager {
}
}
private async syncSessionFiles(params: { needsFullReindex: boolean }) {
private async syncSessionFiles(params: {
needsFullReindex: boolean;
progress?: MemorySyncProgressState;
}) {
const files = await this.listSessionFiles();
const activePaths = new Set(files.map((file) => this.sessionPathForFile(file)));
const indexAll = params.needsFullReindex || this.sessionsDirtyFiles.size === 0;
if (params.progress) {
params.progress.total += files.length;
params.progress.report({
completed: params.progress.completed,
total: params.progress.total,
label: "Indexing session files…",
});
}
for (const absPath of files) {
if (!indexAll && !this.sessionsDirtyFiles.has(absPath)) continue;
if (!indexAll && !this.sessionsDirtyFiles.has(absPath)) {
if (params.progress) {
params.progress.completed += 1;
params.progress.report({
completed: params.progress.completed,
total: params.progress.total,
});
}
continue;
}
const entry = await this.buildSessionEntry(absPath);
if (!entry) continue;
if (!entry) {
if (params.progress) {
params.progress.completed += 1;
params.progress.report({
completed: params.progress.completed,
total: params.progress.total,
});
}
continue;
}
const record = this.db
.prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`)
.get(entry.path, "sessions") as { hash: string } | undefined;
if (!params.needsFullReindex && record?.hash === entry.hash) {
if (params.progress) {
params.progress.completed += 1;
params.progress.report({
completed: params.progress.completed,
total: params.progress.total,
});
}
continue;
}
await this.indexFile(entry, { source: "sessions", content: entry.content });
if (params.progress) {
params.progress.completed += 1;
params.progress.report({
completed: params.progress.completed,
total: params.progress.total,
});
}
}
const staleRows = this.db
@ -709,7 +793,14 @@ export class MemoryIndexManager {
}
}
private async runSync(params?: { reason?: string; force?: boolean }) {
private async runSync(params?: {
reason?: string;
force?: boolean;
progress?: (update: MemorySyncProgressUpdate) => void;
}) {
const progress: MemorySyncProgressState | null = params?.progress
? { completed: 0, total: 0, report: params.progress }
: null;
const vectorReady = await this.ensureVectorReady();
const meta = this.readMeta();
const needsFullReindex =
@ -729,12 +820,12 @@ export class MemoryIndexManager {
const shouldSyncSessions = this.shouldSyncSessions(params, needsFullReindex);
if (shouldSyncMemory) {
await this.syncMemoryFiles({ needsFullReindex });
await this.syncMemoryFiles({ needsFullReindex, progress: progress ?? undefined });
this.dirty = false;
}
if (shouldSyncSessions) {
await this.syncSessionFiles({ needsFullReindex });
await this.syncSessionFiles({ needsFullReindex, progress: progress ?? undefined });
this.sessionsDirty = false;
this.sessionsDirtyFiles.clear();
} else if (needsFullReindex && this.sources.has("sessions")) {

View File

@ -27,6 +27,7 @@ import {
buildTelegramGroupFrom,
buildTelegramGroupPeerId,
buildTypingThreadParams,
expandTextLinks,
normalizeForwardedContext,
describeReplyTarget,
extractTelegramLocation,
@ -271,7 +272,8 @@ export const buildTelegramMessageContext = async ({
const locationData = extractTelegramLocation(msg);
const locationText = locationData ? formatLocationText(locationData) : undefined;
const rawText = (msg.text ?? msg.caption ?? "").trim();
const rawTextSource = msg.text ?? msg.caption ?? "";
const rawText = expandTextLinks(rawTextSource, msg.entities ?? msg.caption_entities).trim();
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
if (!rawBody) rawBody = placeholder;
if (!rawBody && allMedia.length === 0) return null;

View File

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { expandTextLinks } from "./helpers.js";
describe("expandTextLinks", () => {
it("returns text unchanged when no entities are provided", () => {
expect(expandTextLinks("Hello world")).toBe("Hello world");
expect(expandTextLinks("Hello world", null)).toBe("Hello world");
expect(expandTextLinks("Hello world", [])).toBe("Hello world");
});
it("returns text unchanged when there are no text_link entities", () => {
const entities = [
{ type: "mention", offset: 0, length: 5 },
{ type: "bold", offset: 6, length: 5 },
];
expect(expandTextLinks("@user hello", entities)).toBe("@user hello");
});
it("expands a single text_link entity", () => {
const text = "Check this link for details";
const entities = [{ type: "text_link", offset: 11, length: 4, url: "https://example.com" }];
expect(expandTextLinks(text, entities)).toBe(
"Check this [link](https://example.com) for details",
);
});
it("expands multiple text_link entities", () => {
const text = "Visit Google or GitHub for more";
const entities = [
{ type: "text_link", offset: 6, length: 6, url: "https://google.com" },
{ type: "text_link", offset: 16, length: 6, url: "https://github.com" },
];
expect(expandTextLinks(text, entities)).toBe(
"Visit [Google](https://google.com) or [GitHub](https://github.com) for more",
);
});
it("handles adjacent text_link entities", () => {
const text = "AB";
const entities = [
{ type: "text_link", offset: 0, length: 1, url: "https://a.example" },
{ type: "text_link", offset: 1, length: 1, url: "https://b.example" },
];
expect(expandTextLinks(text, entities)).toBe("[A](https://a.example)[B](https://b.example)");
});
it("preserves offsets from the original string", () => {
const text = " Hello world";
const entities = [{ type: "text_link", offset: 1, length: 5, url: "https://example.com" }];
expect(expandTextLinks(text, entities)).toBe(
" [Hello](https://example.com) world",
);
});
});

View File

@ -114,6 +114,37 @@ export function hasBotMention(msg: TelegramMessage, botUsername: string) {
return false;
}
type TelegramTextLinkEntity = {
type: string;
offset: number;
length: number;
url?: string;
};
export function expandTextLinks(
text: string,
entities?: TelegramTextLinkEntity[] | null,
): string {
if (!text || !entities?.length) return text;
const textLinks = entities
.filter(
(entity): entity is TelegramTextLinkEntity & { url: string } =>
entity.type === "text_link" && Boolean(entity.url),
)
.sort((a, b) => b.offset - a.offset);
if (textLinks.length === 0) return text;
let result = text;
for (const entity of textLinks) {
const linkText = text.slice(entity.offset, entity.offset + entity.length);
const markdown = `[${linkText}](${entity.url})`;
result = result.slice(0, entity.offset) + markdown + result.slice(entity.offset + entity.length);
}
return result;
}
export function resolveTelegramReplyId(raw?: string): number | undefined {
if (!raw) return undefined;
const parsed = Number(raw);