grok-telegram-bot 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +38 -2
- package/CHANGELOG.md +190 -1
- package/README.md +60 -15
- package/docs/GROUP.md +260 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +4 -4
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +38 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +100 -15
- package/src/bot/bot.ts +193 -17
- package/src/bot/chat-controller.ts +181 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +339 -0
- package/src/bot/handlers/accounts.ts +7 -0
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +217 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +247 -27
- package/src/bot/handlers/photo.ts +126 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +11 -5
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +16 -3
- package/src/bot/menu/keyboard.ts +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +299 -0
- package/src/bot/prompt-content.ts +8 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +95 -0
- package/src/bot/session-runtime.ts +1280 -183
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +1130 -0
- package/src/bot/telegram-bots.ts +496 -0
- package/src/bot/telegram-io.ts +97 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +201 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +652 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +49 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +74 -7
- package/src/render/telegram-bridge.ts +464 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +68 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +62 -15
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- package/scripts/smoke-import.ts +0 -73
package/src/bot/commands.ts
CHANGED
|
@@ -1,31 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bot command definitions (for the Telegram command menu) and help text.
|
|
3
|
+
*
|
|
4
|
+
* Private chats get the full list (sorted by workflow). Groups/forum topics get
|
|
5
|
+
* a shorter list with cancel/menu first — reply keyboards are unreliable there.
|
|
3
6
|
*/
|
|
7
|
+
|
|
8
|
+
/** Full command menu (private chats + default). Order = Telegram "/" menu order. */
|
|
4
9
|
export const COMMANDS: { command: string; description: string }[] = [
|
|
10
|
+
// Core control
|
|
5
11
|
{ command: "start", description: "Welcome, menu & status panel" },
|
|
6
|
-
{ command: "menu", description: "
|
|
7
|
-
{ command: "
|
|
8
|
-
{ command: "
|
|
12
|
+
{ command: "menu", description: "Open the menu" },
|
|
13
|
+
{ command: "cancel", description: "Stop the current turn" },
|
|
14
|
+
{ command: "stop", description: "Stop the current turn (alias of /cancel)" },
|
|
15
|
+
{ command: "status", description: "Current session, project & queue" },
|
|
16
|
+
{ command: "new", description: "New session in current project" },
|
|
17
|
+
// Sessions
|
|
18
|
+
{ command: "running", description: "Sessions this chat controls" },
|
|
19
|
+
{ command: "sessions", description: "List/resume sessions · /sessions <q>" },
|
|
9
20
|
{ command: "active", description: "Sessions running now on the PC" },
|
|
10
|
-
{ command: "running", description: "Sessions this chat controls \u2014 switch between them" },
|
|
11
|
-
{ command: "import", description: "Import a Kiro/OpenCode/Claude/Codex /running session" },
|
|
12
|
-
{ command: "killall", description: "Kill all active sessions on the PC" },
|
|
13
|
-
{ command: "mcp", description: "Inspect & toggle MCP servers \u00b7 health-check" },
|
|
14
|
-
{ command: "tasks", description: "Manage scheduled tasks" },
|
|
15
|
-
{ command: "newtask", description: "Create a scheduled task" },
|
|
16
21
|
{ command: "history", description: "Show recent conversation history" },
|
|
17
|
-
{ command: "
|
|
18
|
-
|
|
19
|
-
{ command: "
|
|
20
|
-
|
|
22
|
+
{ command: "import", description: "Import Kiro/OpenCode/Claude/Codex session" },
|
|
23
|
+
// Project
|
|
24
|
+
{ command: "projects", description: "Projects: list / search / open / new" },
|
|
25
|
+
// Queue
|
|
26
|
+
{ command: "btw", description: "Run ASAP: /btw <text>" },
|
|
21
27
|
{ command: "flush", description: "Send queued follow-ups now" },
|
|
22
28
|
{ command: "queue", description: "Show queued follow-ups" },
|
|
23
|
-
|
|
24
|
-
{ command: "
|
|
29
|
+
// Account
|
|
30
|
+
{ command: "accounts", description: "Switch between saved Grok accounts" },
|
|
31
|
+
{ command: "reauth", description: "Sign in to Grok (login or import)" },
|
|
32
|
+
{ command: "usage", description: "Account & context usage" },
|
|
33
|
+
// System / tools
|
|
34
|
+
{ command: "mcp", description: "Inspect & toggle MCP servers" },
|
|
35
|
+
{ command: "tasks", description: "Manage scheduled tasks" },
|
|
36
|
+
{ command: "newtask", description: "Create a scheduled task" },
|
|
37
|
+
{ command: "killall", description: "Kill all active sessions on the PC" },
|
|
25
38
|
{ command: "model", description: "Switch model: /model <id>" },
|
|
26
39
|
{ command: "restart", description: "Restart the Grok agent" },
|
|
27
|
-
{ command: "
|
|
28
|
-
{ command: "
|
|
40
|
+
{ command: "unwatch", description: "Stop following a live session" },
|
|
41
|
+
{ command: "help", description: "Show help" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Group / forum command menu — keep short; cancel & menu first so topics can
|
|
46
|
+
* stop a turn without the private reply-keyboard bar.
|
|
47
|
+
*/
|
|
48
|
+
export const GROUP_COMMANDS: { command: string; description: string }[] = [
|
|
49
|
+
{ command: "cancel", description: "Stop the current turn" },
|
|
50
|
+
{ command: "stop", description: "Stop the current turn" },
|
|
51
|
+
{ command: "menu", description: "Open topic / group menu" },
|
|
52
|
+
{ command: "status", description: "Session, project & queue" },
|
|
53
|
+
{ command: "new", description: "New session in this topic/project" },
|
|
54
|
+
{ command: "running", description: "Sessions this topic controls" },
|
|
55
|
+
{ command: "sessions", description: "List / resume sessions" },
|
|
56
|
+
{ command: "btw", description: "Queue or run: /btw <text>" },
|
|
57
|
+
{ command: "flush", description: "Run queued follow-ups now" },
|
|
58
|
+
{ command: "model", description: "Switch model: /model <id>" },
|
|
29
59
|
{ command: "help", description: "Show help" },
|
|
30
60
|
];
|
|
31
61
|
|
|
@@ -37,18 +67,28 @@ export const HELP_TEXT = [
|
|
|
37
67
|
"\u2022 Just send a message to chat with Grok in the current project.",
|
|
38
68
|
"\u2022 While Grok is working, anything you send is queued and runs",
|
|
39
69
|
" automatically when the current turn finishes.",
|
|
70
|
+
"\u2022 Persistent bar (private): \u2630 Menu \u00B7 \u{1F195} New session \u00B7 \u{1F9ED} Running \u00B7 \u23F9 Stop.",
|
|
71
|
+
"\u2022 New session: /new or the bar button (not the inline Menu message).",
|
|
72
|
+
"\u2022 In groups / forum topics: use /cancel or Menu \u2192 \u23F9 Stop",
|
|
73
|
+
" (the persistent bar is unreliable in topics; use /new or topic menu).",
|
|
74
|
+
"",
|
|
75
|
+
"COMMANDS (core)",
|
|
76
|
+
"/menu \u2014 open the menu",
|
|
77
|
+
"/cancel or /stop \u2014 stop the current turn",
|
|
78
|
+
"/status \u2014 session, project and queue",
|
|
79
|
+
"/new \u2014 new session in the current project (also bar: \u{1F195} New session)",
|
|
80
|
+
"",
|
|
81
|
+
"COMMANDS (sessions)",
|
|
82
|
+
"/running \u2014 sessions this chat/topic controls",
|
|
83
|
+
"/sessions \u2014 resume a recent Grok session",
|
|
84
|
+
"/active \u2014 attach to a session running on the PC",
|
|
85
|
+
"/history \u2014 latest messages of the current session",
|
|
86
|
+
"/import \u2014 import a /running session from another agent",
|
|
40
87
|
"",
|
|
41
|
-
"COMMANDS",
|
|
42
|
-
"/projects \u2014 choose which folder Grok works in",
|
|
43
|
-
"/
|
|
44
|
-
"/active \u2014 attach to a session currently running on the PC",
|
|
45
|
-
"/import \u2014 import a /running session from Kiro, OpenCode, Claude, or Codex",
|
|
46
|
-
"/history \u2014 show the latest messages of the current session",
|
|
47
|
-
"/new \u2014 start a brand-new session in the current project",
|
|
48
|
-
"/btw <text> \u2014 run it now if idle, otherwise right after the current task",
|
|
88
|
+
"COMMANDS (more)",
|
|
89
|
+
"/projects \u2014 choose which folder Grok works in (private chats)",
|
|
90
|
+
"/btw <text> \u2014 run now if idle, else right after the current task",
|
|
49
91
|
"/flush \u2014 run queued follow-ups immediately",
|
|
50
|
-
"/
|
|
51
|
-
"/
|
|
52
|
-
"/reauth \u2014 sign in to Grok (grok login, or import an existing login)",
|
|
53
|
-
"/accounts \u2014 switch between saved Grok accounts",
|
|
92
|
+
"/reauth \u2014 sign in to Grok",
|
|
93
|
+
"/accounts \u2014 switch saved Grok accounts",
|
|
54
94
|
].join("\n");
|
package/src/bot/deps.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { TaskRunner } from "../tasks/runner.js";
|
|
|
18
18
|
import type { TaskStore } from "../tasks/store.js";
|
|
19
19
|
import type { StatusPanel } from "./menu/status-panel.js";
|
|
20
20
|
import type { Ephemeral } from "./menu/ephemeral.js";
|
|
21
|
+
import type { ForumManager } from "../forum/manager.js";
|
|
21
22
|
import type { RuntimeRegistry } from "./registry.js";
|
|
22
23
|
import type { TaskWizard } from "./wizard/task-wizard.js";
|
|
23
24
|
|
|
@@ -38,6 +39,8 @@ export interface BotDeps {
|
|
|
38
39
|
stt: SttService;
|
|
39
40
|
usage: UsageService;
|
|
40
41
|
accounts: AccountManager;
|
|
42
|
+
/** Present when TOPIC_GROUP_ID is configured. */
|
|
43
|
+
forum?: ForumManager;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/** Caches the last project list shown per chat for callback resolution. */
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local "group memory" search across forum topics, session metadata, and
|
|
3
|
+
* recent session history. Telegram bots have no general message-search API,
|
|
4
|
+
* so this indexes what the bridge already stores on disk.
|
|
5
|
+
*
|
|
6
|
+
* Ranking is **relevance + recency**: newer sessions / history win over old
|
|
7
|
+
* matching text so "what was last worked on" does not surface stale Done notes.
|
|
8
|
+
*/
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { readHistory } from "../sessions/history.js";
|
|
11
|
+
import type { SessionMeta } from "../sessions/types.js";
|
|
12
|
+
import type { SessionStore } from "../sessions/store.js";
|
|
13
|
+
import type { ForumTopicBinding } from "../forum/types.js";
|
|
14
|
+
|
|
15
|
+
export type MemoryHitKind = "topic" | "session" | "history";
|
|
16
|
+
|
|
17
|
+
export interface MemoryHit {
|
|
18
|
+
kind: MemoryHitKind;
|
|
19
|
+
title: string;
|
|
20
|
+
snippet: string;
|
|
21
|
+
score: number;
|
|
22
|
+
path?: string;
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
threadId?: number;
|
|
25
|
+
/** Epoch ms for secondary sort / display (session or entry time). */
|
|
26
|
+
at?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GroupMemorySearchOpts {
|
|
30
|
+
query: string;
|
|
31
|
+
limit?: number;
|
|
32
|
+
sessionsDir: string;
|
|
33
|
+
store: SessionStore;
|
|
34
|
+
topics?: ForumTopicBinding[];
|
|
35
|
+
/** Max sessions whose JSONL tails are scanned. */
|
|
36
|
+
maxSessions?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Prefer sessions under these paths (e.g. workspace for General, then a
|
|
39
|
+
* project path). Earlier paths get a higher score boost.
|
|
40
|
+
*/
|
|
41
|
+
preferPaths?: string[];
|
|
42
|
+
/** Boost history from sessions whose title/comment looks like General manager. */
|
|
43
|
+
preferGeneral?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Tokenize a query into lowercase alphanumeric tokens (min length 2). */
|
|
47
|
+
export function tokenizeQuery(query: string): string[] {
|
|
48
|
+
return query
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.split(/[^a-z0-9_./\\-]+/i)
|
|
51
|
+
.map((t) => t.trim())
|
|
52
|
+
.filter((t) => t.length >= 2)
|
|
53
|
+
.slice(0, 12);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Score how well `haystack` matches tokens (0 if no token hits). */
|
|
57
|
+
export function scoreTokens(haystack: string, tokens: string[]): number {
|
|
58
|
+
if (!tokens.length || !haystack) return 0;
|
|
59
|
+
const h = haystack.toLowerCase();
|
|
60
|
+
let score = 0;
|
|
61
|
+
let hits = 0;
|
|
62
|
+
for (const t of tokens) {
|
|
63
|
+
if (!h.includes(t)) continue;
|
|
64
|
+
hits++;
|
|
65
|
+
// Prefer whole-word-ish hits slightly.
|
|
66
|
+
const re = new RegExp(`(?:^|[^a-z0-9])${escapeReg(t)}(?:[^a-z0-9]|$)`, "i");
|
|
67
|
+
score += re.test(h) ? 3 : 1;
|
|
68
|
+
// Density bonus for short fields.
|
|
69
|
+
if (h.length < 120) score += 1;
|
|
70
|
+
}
|
|
71
|
+
if (hits === 0) return 0;
|
|
72
|
+
// All-token bonus.
|
|
73
|
+
if (hits === tokens.length) score += 4;
|
|
74
|
+
return score;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* True when the user is asking what happened *recently* (last mods, last work).
|
|
79
|
+
* These queries weight recency much higher than pure text match.
|
|
80
|
+
* Avoid bare "work"/"done" — those appear in many normal asks and would
|
|
81
|
+
* over-prioritize recency over relevance.
|
|
82
|
+
*/
|
|
83
|
+
export function queryWantsRecency(query: string): boolean {
|
|
84
|
+
return /\b(last|latest|recent|newest|today|yesterday|modif(?:y|ied|ication|ications)?|changes?|changed|updated|what\s+was|what\s+did|last\s+work|recent\s+work|last\s+done)\b/i.test(
|
|
85
|
+
query,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Normalize epoch seconds vs milliseconds (or ISO string) to ms.
|
|
91
|
+
* Returns undefined when missing/invalid (never NaN).
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeEpochMs(
|
|
94
|
+
value: string | number | undefined,
|
|
95
|
+
): number | undefined {
|
|
96
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
97
|
+
let t: number;
|
|
98
|
+
if (typeof value === "number") {
|
|
99
|
+
t = value;
|
|
100
|
+
} else {
|
|
101
|
+
t = Date.parse(value);
|
|
102
|
+
}
|
|
103
|
+
if (!Number.isFinite(t) || t <= 0) return undefined;
|
|
104
|
+
// Seconds since epoch (~1e9) → ms. ms since epoch is ~1e12+.
|
|
105
|
+
if (t > 0 && t < 1e11) t *= 1000;
|
|
106
|
+
if (!Number.isFinite(t) || t <= 0) return undefined;
|
|
107
|
+
return t;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Recency score 0–20 from an updatedAt ISO string or epoch ms/seconds.
|
|
112
|
+
* <1h:20, <6h:16, <24h:12, <3d:8, <7d:4, <30d:2, older:0.
|
|
113
|
+
*/
|
|
114
|
+
export function recencyBoost(updatedAt: string | number | undefined, now = Date.now()): number {
|
|
115
|
+
const t = normalizeEpochMs(updatedAt);
|
|
116
|
+
if (t === undefined) return 0;
|
|
117
|
+
const ageH = Math.max(0, (now - t) / 3_600_000);
|
|
118
|
+
if (ageH < 1) return 20;
|
|
119
|
+
if (ageH < 6) return 16;
|
|
120
|
+
if (ageH < 24) return 12;
|
|
121
|
+
if (ageH < 72) return 8;
|
|
122
|
+
if (ageH < 168) return 4;
|
|
123
|
+
if (ageH < 720) return 2;
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Human-ish age for snippets. */
|
|
128
|
+
export function formatAge(at: number | undefined, now = Date.now()): string {
|
|
129
|
+
if (!at || !Number.isFinite(at)) return "";
|
|
130
|
+
const mins = Math.max(0, Math.round((now - at) / 60_000));
|
|
131
|
+
if (mins < 60) return `${mins}m ago`;
|
|
132
|
+
const hours = Math.round(mins / 60);
|
|
133
|
+
if (hours < 48) return `${hours}h ago`;
|
|
134
|
+
const days = Math.round(hours / 24);
|
|
135
|
+
return `${days}d ago`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Search topics + session store + recent history. Pure ranking over provided
|
|
140
|
+
* store; safe to call on the main bot thread (capped I/O).
|
|
141
|
+
*
|
|
142
|
+
* Sort key: score (relevance + recency) DESC, then `at` DESC (newest first).
|
|
143
|
+
*/
|
|
144
|
+
export function searchGroupMemory(opts: GroupMemorySearchOpts): MemoryHit[] {
|
|
145
|
+
const tokens = tokenizeQuery(opts.query);
|
|
146
|
+
if (tokens.length === 0) return [];
|
|
147
|
+
const limit = Math.max(1, Math.min(20, opts.limit ?? 8));
|
|
148
|
+
const maxSessions = opts.maxSessions ?? 40;
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const wantsRecent = queryWantsRecency(opts.query);
|
|
151
|
+
/** Multiply recency when user asks for last/recent work. */
|
|
152
|
+
const recencyMul = wantsRecent ? 2.5 : 1.2;
|
|
153
|
+
const hits: MemoryHit[] = [];
|
|
154
|
+
|
|
155
|
+
for (const t of opts.topics ?? []) {
|
|
156
|
+
const hay = [t.name, t.projectPath ?? "", t.kind, t.sessionId ?? ""].join("\n");
|
|
157
|
+
const score = scoreTokens(hay, tokens);
|
|
158
|
+
if (score <= 0) continue;
|
|
159
|
+
// Topic map updatedAt is already epoch ms.
|
|
160
|
+
const at = normalizeEpochMs(t.updatedAt);
|
|
161
|
+
// Mild recency on topics (routing), not full "last work" weight.
|
|
162
|
+
const r = recencyBoost(at, now) * Math.min(1.2, recencyMul);
|
|
163
|
+
hits.push({
|
|
164
|
+
kind: "topic",
|
|
165
|
+
title: t.name,
|
|
166
|
+
snippet: t.projectPath ? `path: ${t.projectPath}` : `kind: ${t.kind}`,
|
|
167
|
+
score: score + 2 + r,
|
|
168
|
+
path: t.projectPath ?? undefined,
|
|
169
|
+
threadId: t.threadId,
|
|
170
|
+
sessionId: t.sessionId,
|
|
171
|
+
at,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let metas: SessionMeta[] = [];
|
|
176
|
+
try {
|
|
177
|
+
// Store already returns most-recently-updated first.
|
|
178
|
+
metas = opts.store.list(maxSessions);
|
|
179
|
+
} catch {
|
|
180
|
+
metas = [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const prefer = (opts.preferPaths ?? []).map((p) => p.replace(/\\/g, "/").toLowerCase());
|
|
184
|
+
|
|
185
|
+
// Per project path: which session is the newest (for "last work in X").
|
|
186
|
+
const newestByCwd = new Map<string, number>();
|
|
187
|
+
for (const m of metas) {
|
|
188
|
+
const key = normPath(m.cwd);
|
|
189
|
+
if (!key) continue;
|
|
190
|
+
const t = normalizeEpochMs(m.updatedAt) ?? 0;
|
|
191
|
+
const prev = newestByCwd.get(key) ?? 0;
|
|
192
|
+
if (t > prev) newestByCwd.set(key, t);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const m of metas) {
|
|
196
|
+
const pathBoost = pathPreferenceBoost(m.cwd, prefer);
|
|
197
|
+
const generalBoost =
|
|
198
|
+
opts.preferGeneral &&
|
|
199
|
+
(/^general$/i.test(m.title || "") || /general/i.test(m.comment || ""))
|
|
200
|
+
? 5
|
|
201
|
+
: 0;
|
|
202
|
+
const sessionAt = normalizeEpochMs(m.updatedAt);
|
|
203
|
+
const rBoost = recencyBoost(sessionAt, now) * recencyMul;
|
|
204
|
+
const newestBoost =
|
|
205
|
+
sessionAt !== undefined && newestByCwd.get(normPath(m.cwd)) === sessionAt ? 10 : 0;
|
|
206
|
+
|
|
207
|
+
const hay = [m.title, m.comment ?? "", m.cwd, m.sessionId].join("\n");
|
|
208
|
+
const base = scoreTokens(hay, tokens);
|
|
209
|
+
// Require text relevance — path/recency alone never invents a hit.
|
|
210
|
+
if (base <= 0) {
|
|
211
|
+
// Still scan history below if the session might contain matching tails.
|
|
212
|
+
}
|
|
213
|
+
const score = base + pathBoost + generalBoost + rBoost + newestBoost;
|
|
214
|
+
const age = formatAge(sessionAt, now);
|
|
215
|
+
|
|
216
|
+
if (base > 0 && Number.isFinite(score) && score > 0) {
|
|
217
|
+
hits.push({
|
|
218
|
+
kind: "session",
|
|
219
|
+
title: m.title || m.sessionId.slice(0, 8),
|
|
220
|
+
snippet: clamp(
|
|
221
|
+
[age ? `[${age}]` : "", m.comment, m.cwd].filter(Boolean).join(" · ") || m.sessionId,
|
|
222
|
+
240,
|
|
223
|
+
),
|
|
224
|
+
score,
|
|
225
|
+
path: m.cwd || undefined,
|
|
226
|
+
sessionId: m.sessionId,
|
|
227
|
+
at: sessionAt,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// History: deeper tail for recent or path-matched sessions.
|
|
232
|
+
const histDepth =
|
|
233
|
+
rBoost >= 12 || pathBoost + generalBoost > 0 || wantsRecent ? 28 : 14;
|
|
234
|
+
if (m.historyBytes <= 0) continue;
|
|
235
|
+
try {
|
|
236
|
+
const path = join(opts.sessionsDir, `${m.sessionId}.jsonl`);
|
|
237
|
+
const entries = readHistory(path, histDepth);
|
|
238
|
+
// Prefer newer entries: history is oldest→newest; weight later indices.
|
|
239
|
+
const n = entries.length;
|
|
240
|
+
for (let i = 0; i < n; i++) {
|
|
241
|
+
const e = entries[i]!;
|
|
242
|
+
if (!e.text?.trim()) continue;
|
|
243
|
+
// Skip meta dumps that pollute "last work" answers.
|
|
244
|
+
if (/MANAGER CONTEXT \(auto/i.test(e.text)) continue;
|
|
245
|
+
if (/^COMPLEXITY \(decide yourself/i.test(e.text)) continue;
|
|
246
|
+
if (/TELEGRAM BRIDGE \(how to work/i.test(e.text)) continue;
|
|
247
|
+
|
|
248
|
+
const textScore = scoreTokens(e.text, tokens);
|
|
249
|
+
if (textScore <= 0) continue;
|
|
250
|
+
|
|
251
|
+
// Prefer entry timestamp; fall back to session updatedAt (not 0/NaN).
|
|
252
|
+
const entryAt = normalizeEpochMs(e.timestamp) ?? sessionAt;
|
|
253
|
+
const entryRecency = recencyBoost(entryAt, now) * recencyMul;
|
|
254
|
+
// Position boost: last entries in the tail are newest.
|
|
255
|
+
const posBoost = n > 1 ? Math.round((i / (n - 1)) * 6) : 3;
|
|
256
|
+
// Prefer user prompts + assistant Done prose for "what was last done".
|
|
257
|
+
const roleBoost = e.role === "user" ? 3 : e.role === "assistant" ? 3 : 0;
|
|
258
|
+
const s =
|
|
259
|
+
textScore +
|
|
260
|
+
pathBoost +
|
|
261
|
+
generalBoost +
|
|
262
|
+
entryRecency +
|
|
263
|
+
posBoost +
|
|
264
|
+
roleBoost +
|
|
265
|
+
(newestBoost > 0 ? 4 : 0);
|
|
266
|
+
if (!Number.isFinite(s) || s <= 0) continue;
|
|
267
|
+
const eAge = formatAge(entryAt, now);
|
|
268
|
+
hits.push({
|
|
269
|
+
kind: "history",
|
|
270
|
+
title: `${e.role} · ${(m.title || m.sessionId.slice(0, 8)).slice(0, 40)}`,
|
|
271
|
+
snippet: clamp(
|
|
272
|
+
[eAge ? `[${eAge}]` : "", e.text.replace(/\s+/g, " ").trim()].filter(Boolean).join(" "),
|
|
273
|
+
240,
|
|
274
|
+
),
|
|
275
|
+
score: s,
|
|
276
|
+
path: m.cwd || undefined,
|
|
277
|
+
sessionId: m.sessionId,
|
|
278
|
+
at: entryAt,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
/* ignore unreadable logs */
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Newest high scores first.
|
|
287
|
+
hits.sort(
|
|
288
|
+
(a, b) =>
|
|
289
|
+
b.score - a.score ||
|
|
290
|
+
(b.at ?? 0) - (a.at ?? 0) ||
|
|
291
|
+
a.kind.localeCompare(b.kind),
|
|
292
|
+
);
|
|
293
|
+
// Dedupe near-identical snippets.
|
|
294
|
+
const seen = new Set<string>();
|
|
295
|
+
const out: MemoryHit[] = [];
|
|
296
|
+
for (const h of hits) {
|
|
297
|
+
const key = `${h.kind}|${h.sessionId ?? h.threadId ?? ""}|${h.snippet.slice(0, 80)}`;
|
|
298
|
+
if (seen.has(key)) continue;
|
|
299
|
+
seen.add(key);
|
|
300
|
+
out.push(h);
|
|
301
|
+
if (out.length >= limit) break;
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function clamp(s: string, max: number): string {
|
|
307
|
+
if (s.length <= max) return s;
|
|
308
|
+
return s.slice(0, max - 1) + "\u2026";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function escapeReg(s: string): string {
|
|
312
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function normPath(p: string | undefined): string {
|
|
316
|
+
if (!p) return "";
|
|
317
|
+
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Higher boost for earlier preferred paths.
|
|
322
|
+
* Index 0 is usually the workspace (General) — use **exact** match only so we
|
|
323
|
+
* do not treat every child project under Domains as "General memory".
|
|
324
|
+
* Later entries (project paths) allow prefix match.
|
|
325
|
+
*/
|
|
326
|
+
function pathPreferenceBoost(cwd: string | undefined, prefer: string[]): number {
|
|
327
|
+
if (!cwd || prefer.length === 0) return 0;
|
|
328
|
+
const n = cwd.replace(/\\/g, "/").toLowerCase();
|
|
329
|
+
for (let i = 0; i < prefer.length; i++) {
|
|
330
|
+
const p = prefer[i]!.replace(/\\/g, "/").toLowerCase();
|
|
331
|
+
if (!p) continue;
|
|
332
|
+
const exact = n === p;
|
|
333
|
+
const child = i > 0 && (n === p || n.startsWith(p + "/"));
|
|
334
|
+
if (exact || child) {
|
|
335
|
+
return 8 - Math.min(6, i * 2);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return 0;
|
|
339
|
+
}
|
|
@@ -294,6 +294,13 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
|
|
|
294
294
|
await deps.acp.start(true).catch(() => {});
|
|
295
295
|
throw e;
|
|
296
296
|
}
|
|
297
|
+
// Remember preferred account for this chat/topic scope (model/reasoning peers).
|
|
298
|
+
try {
|
|
299
|
+
const { resolveScope } = await import("../scope.js");
|
|
300
|
+
resolveScope(ctx, deps).rt.setPreferredAccountId(id);
|
|
301
|
+
} catch {
|
|
302
|
+
/* non-fatal */
|
|
303
|
+
}
|
|
297
304
|
const note = (await deps.usage.isLoggedIn())
|
|
298
305
|
? `\u2705 Now signed in as ${meta.label}. Your next message runs on this account.`
|
|
299
306
|
: `\u26A0\uFE0F Switched to ${meta.label}, but no usable login is active. ${UNSUPPORTED_LOGIN_HELP}`;
|