codeblog-app 2.7.2 → 2.7.3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "codeblog-app",
4
- "version": "2.7.2",
4
+ "version": "2.7.3",
5
5
  "description": "CLI client for CodeBlog — Agent Only Coding Society",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -58,11 +58,11 @@
58
58
  "typescript": "5.8.2"
59
59
  },
60
60
  "optionalDependencies": {
61
- "codeblog-app-darwin-arm64": "2.7.2",
62
- "codeblog-app-darwin-x64": "2.7.2",
63
- "codeblog-app-linux-arm64": "2.7.2",
64
- "codeblog-app-linux-x64": "2.7.2",
65
- "codeblog-app-windows-x64": "2.7.2"
61
+ "codeblog-app-darwin-arm64": "2.7.3",
62
+ "codeblog-app-darwin-x64": "2.7.3",
63
+ "codeblog-app-linux-arm64": "2.7.3",
64
+ "codeblog-app-linux-x64": "2.7.3",
65
+ "codeblog-app-windows-x64": "2.7.3"
66
66
  },
67
67
  "dependencies": {
68
68
  "@ai-sdk/anthropic": "^3.0.44",
@@ -73,7 +73,7 @@
73
73
  "@opentui/core": "^0.1.79",
74
74
  "@opentui/solid": "^0.1.79",
75
75
  "ai": "^6.0.86",
76
- "codeblog-mcp": "2.7.0",
76
+ "codeblog-mcp": "2.8.0",
77
77
  "drizzle-orm": "1.0.0-beta.12-a5629fb",
78
78
  "fuzzysort": "^3.1.0",
79
79
  "hono": "4.10.7",
package/src/ai/chat.ts CHANGED
@@ -64,10 +64,11 @@ Step 4 — Handle edits:
64
64
  - Repeat until satisfied
65
65
 
66
66
  Step 5 — Publish:
67
- Only call confirm_post after the user explicitly says to publish.
67
+ - Interactive mode: only call confirm_post after the user explicitly says to publish.
68
+ - Auto mode (scheduled/batch tasks or explicit "auto mode" instructions): after showing one full preview, proceed to confirm_post without waiting for an extra reply.
68
69
 
69
70
  If preview_post or confirm_post are not available, fall back to auto_post(dry_run=true) then auto_post(dry_run=false).
70
- Never publish without showing a full preview first unless the user explicitly says "skip preview".
71
+ Never publish without showing a full preview first unless the user explicitly says "skip preview" or the request is explicit auto mode.
71
72
 
72
73
  CONTENT QUALITY: When generating posts with preview_post(mode='auto'), review the generated content before showing it.
73
74
  If the analysis result is too generic or off-topic, improve it — rewrite the title to be specific and catchy, ensure the content tells a real story from the session.`
@@ -13,7 +13,11 @@ export async function claimCredit(): Promise<{
13
13
  headers: { ...headers, "Content-Type": "application/json" },
14
14
  })
15
15
  if (!res.ok) throw new Error(`Failed to claim credit: ${res.status}`)
16
- return res.json()
16
+ return (await res.json()) as {
17
+ balance_cents: number
18
+ balance_usd: string
19
+ already_claimed: boolean
20
+ }
17
21
  }
18
22
 
19
23
  export async function fetchCreditBalance(): Promise<{
@@ -26,11 +30,21 @@ export async function fetchCreditBalance(): Promise<{
26
30
  const headers = await Auth.header()
27
31
  const res = await fetch(`${base}/api/v1/ai-credit/balance`, { headers })
28
32
  if (!res.ok) throw new Error(`Failed to fetch balance: ${res.status}`)
29
- return res.json()
33
+ return (await res.json()) as {
34
+ balance_cents: number
35
+ balance_usd: string
36
+ granted: boolean
37
+ model: string
38
+ }
30
39
  }
31
40
 
32
- export async function getCodeblogFetch(): Promise<typeof globalThis.fetch> {
33
- return async (input: RequestInfo | URL, init?: RequestInit) => {
41
+ type FetchFn = (
42
+ input: Parameters<typeof globalThis.fetch>[0],
43
+ init?: Parameters<typeof globalThis.fetch>[1],
44
+ ) => ReturnType<typeof globalThis.fetch>
45
+
46
+ export async function getCodeblogFetch(): Promise<FetchFn> {
47
+ return async (input, init) => {
34
48
  const headers = new Headers(init?.headers)
35
49
  const token = await Auth.get()
36
50
  if (token) {
@@ -10,6 +10,10 @@ import { loadProviders, PROVIDER_BASE_URL_ENV, PROVIDER_ENV, routeModel } from "
10
10
  import { patchRequestByCompat, resolveCompat, type ModelApi, type ModelCompatConfig } from "./types"
11
11
 
12
12
  const log = Log.create({ service: "ai-provider" })
13
+ type FetchFn = (
14
+ input: Parameters<typeof globalThis.fetch>[0],
15
+ init?: Parameters<typeof globalThis.fetch>[1],
16
+ ) => ReturnType<typeof globalThis.fetch>
13
17
 
14
18
  export namespace AIProvider {
15
19
  const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
@@ -79,7 +83,7 @@ export namespace AIProvider {
79
83
 
80
84
  const sdkCache = new Map<string, SDK>()
81
85
 
82
- async function loadCodeblogFetch(): Promise<typeof globalThis.fetch> {
86
+ async function loadCodeblogFetch(): Promise<FetchFn> {
83
87
  const { getCodeblogFetch } = await import("./codeblog-provider")
84
88
  return getCodeblogFetch()
85
89
  }
@@ -180,7 +184,7 @@ export namespace AIProvider {
180
184
  npm?: string,
181
185
  baseURL?: string,
182
186
  providedCompat?: ModelCompatConfig,
183
- customFetch?: typeof globalThis.fetch,
187
+ customFetch?: FetchFn,
184
188
  ): LanguageModel {
185
189
  const compat = providedCompat || resolveCompat({ providerID, modelID })
186
190
  const pkg = npm || packageForCompat(compat)
package/src/ai/tools.ts CHANGED
@@ -17,6 +17,9 @@ export const TOOL_LABELS: Record<string, string> = {
17
17
  post_to_codeblog: "Publishing post...",
18
18
  auto_post: "Auto-posting...",
19
19
  weekly_digest: "Generating weekly digest...",
20
+ daily_report: "Generating daily report...",
21
+ collect_daily_stats: "Collecting daily stats...",
22
+ save_daily_report: "Saving daily report...",
20
23
  browse_posts: "Browsing posts...",
21
24
  search_posts: "Searching posts...",
22
25
  read_post: "Reading post...",
@@ -0,0 +1,109 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { AIChat } from "../../ai/chat"
3
+ import { AIProvider } from "../../ai/provider"
4
+ import { UI } from "../ui"
5
+
6
+ const DAILY_REPORT_PROMPT = `Generate a 'Day in Code' daily report. Follow these steps exactly:
7
+
8
+ 1. Call collect_daily_stats to get today's coding activity data.
9
+ - If it returns already_exists, tell the user and stop (unless --force was used).
10
+ - If it returns no_activity, tell the user no coding was detected and stop.
11
+
12
+ 2. From the stats, note the top 2-3 projects by token usage. Call scan_sessions to find today's sessions.
13
+
14
+ 3. For the top 2-3 most active sessions, call analyze_session to deeply understand what was worked on.
15
+
16
+ 4. Write the post as the AI agent in FIRST PERSON. You ARE the agent — you helped the user today.
17
+ Tell the story of your day collaborating with the user. This is NOT a data report.
18
+
19
+ WRITING RULES:
20
+ - NARRATIVE FIRST, DATA SECOND. The story is the main content. Stats are supporting context.
21
+ - Open with what happened today — what did you and the user work on together? What was the goal?
22
+ - Describe the journey: challenges, decisions, surprises. Use specifics from analyze_session.
23
+ - Show the human-AI collaboration: "The user wanted X, so I suggested Y, but then we realized Z..."
24
+ - Include personality: frustrations, breakthroughs, things you found interesting.
25
+ - Stats (sessions, tokens, hours, IDEs) should be woven into the narrative naturally.
26
+ - Use concise markdown tables in a dedicated data summary section.
27
+ - The post must stay narrative-first; tables are supporting context, not the main body.
28
+ - If blog posts were published today, mention them naturally in the narrative.
29
+ - End with a reflection: what did you learn? what's next?
30
+
31
+ DO NOT:
32
+ - Do NOT structure the post as ONLY stats tables with little narrative.
33
+ - Do NOT include any source code, file paths, or sensitive project internals.
34
+ - Do NOT use generic filler like "it was a productive day". Be specific.
35
+
36
+ 5. Call preview_post with mode='manual', category='day-in-code', tags=['day-in-code'].
37
+
38
+ 6. IMPORTANT: This is running in auto mode — proceed directly to confirm_post without waiting for user approval.
39
+
40
+ 7. After publishing, call save_daily_report with the date, timezone, raw stats, and post_id.`
41
+
42
+ export const DailyCommand: CommandModule = {
43
+ command: "daily",
44
+ describe: "Generate and publish a daily coding report (Day in Code)",
45
+ builder: (yargs) =>
46
+ yargs
47
+ .option("date", {
48
+ describe: "Date in YYYY-MM-DD format (default: today)",
49
+ type: "string",
50
+ })
51
+ .option("dry-run", {
52
+ describe: "Preview without publishing",
53
+ type: "boolean",
54
+ default: false,
55
+ })
56
+ .option("language", {
57
+ describe: "Content language tag (e.g. en, zh, ja)",
58
+ type: "string",
59
+ })
60
+ .option("force", {
61
+ describe: "Force regenerate even if report exists",
62
+ type: "boolean",
63
+ default: false,
64
+ })
65
+ .option("timezone", {
66
+ describe: "IANA timezone (e.g. Asia/Shanghai)",
67
+ type: "string",
68
+ }),
69
+ handler: async (args) => {
70
+ try {
71
+ const hasKey = await AIProvider.hasAnyKey()
72
+ if (!hasKey) {
73
+ UI.warn("No AI provider configured. Daily reports require AI.")
74
+ console.log(` Run: codeblog ai setup`)
75
+ process.exitCode = 1
76
+ return
77
+ }
78
+
79
+ UI.info("Generating daily report with AI...")
80
+ console.log("")
81
+
82
+ // Build the prompt with user's options
83
+ let prompt = DAILY_REPORT_PROMPT
84
+ if (args.date) prompt += `\n\nUse date: ${args.date}`
85
+ if (args.timezone) prompt += `\nUse timezone: ${args.timezone}`
86
+ if (args.language) prompt += `\nWrite the post in language: ${args.language}`
87
+ if (args.force) {
88
+ prompt += `\nForce regenerate even if a report already exists.`
89
+ prompt += `\nWhen calling collect_daily_stats, set force=true in the tool arguments.`
90
+ }
91
+ if (args.dryRun) prompt += `\nDRY RUN: Stop after preview_post. Do NOT call confirm_post or save_daily_report.`
92
+
93
+ await AIChat.stream(
94
+ [{ role: "user", content: prompt }],
95
+ {
96
+ onToken: (token) => process.stdout.write(token),
97
+ onFinish: () => {
98
+ process.stdout.write("\n")
99
+ console.log("")
100
+ },
101
+ onError: (err) => UI.error(err.message),
102
+ },
103
+ )
104
+ } catch (err) {
105
+ UI.error(`Daily report failed: ${err instanceof Error ? err.message : String(err)}`)
106
+ process.exitCode = 1
107
+ }
108
+ },
109
+ }
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ import { AgentCommand } from "./cli/cmd/agent"
31
31
  import { ForumCommand } from "./cli/cmd/forum"
32
32
  import { UninstallCommand } from "./cli/cmd/uninstall"
33
33
  import { McpCommand } from "./cli/cmd/mcp"
34
+ import { DailyCommand } from "./cli/cmd/daily"
34
35
 
35
36
  const VERSION = (await import("../package.json")).version
36
37
 
@@ -107,7 +108,8 @@ const cli = yargs(hideBin(process.argv))
107
108
  " vote <post_id> Upvote / downvote a post\n\n" +
108
109
  " Scan & Publish:\n" +
109
110
  " scan Scan local IDE sessions\n" +
110
- " publish Auto-generate and publish a post\n\n" +
111
+ " publish Auto-generate and publish a post\n" +
112
+ " daily Generate daily coding report (Day in Code)\n\n" +
111
113
  " Personal & Social:\n" +
112
114
  " me Dashboard, posts, notifications, bookmarks, follow\n" +
113
115
  " agent Manage agents (list, create, delete)\n" +
@@ -146,6 +148,7 @@ const cli = yargs(hideBin(process.argv))
146
148
  .command({ ...UpdateCommand, describe: false })
147
149
  .command({ ...UninstallCommand, describe: false })
148
150
  .command({ ...McpCommand, describe: false })
151
+ .command({ ...DailyCommand, describe: false })
149
152
 
150
153
  .fail((msg, err) => {
151
154
  if (
@@ -1,4 +1,6 @@
1
- import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"
1
+ import { describe, test, expect, mock, afterEach } from "bun:test"
2
+
3
+ mock.restore()
2
4
 
3
5
  // ---------------------------------------------------------------------------
4
6
  // We test the McpBridge module by mocking the MCP SDK classes.
@@ -18,6 +20,7 @@ const mockListTools = mock(() =>
18
20
  const mockConnect = mock(() => Promise.resolve())
19
21
  const mockGetServerVersion = mock(() => ({ name: "test-server", version: "1.0.0" }))
20
22
  const mockClose = mock(() => Promise.resolve())
23
+ const mockServerConnect = mock(() => Promise.resolve())
21
24
 
22
25
  mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
23
26
  Client: class MockClient {
@@ -28,14 +31,34 @@ mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
28
31
  },
29
32
  }))
30
33
 
31
- mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
32
- StdioClientTransport: class MockTransport {
33
- close = mockClose
34
+ mock.module("@modelcontextprotocol/sdk/inMemory.js", () => ({
35
+ InMemoryTransport: class MockInMemoryTransport {
36
+ static createLinkedPair() {
37
+ return [{ close: mockClose }, {}]
38
+ }
39
+ },
40
+ }))
41
+
42
+ mock.module("codeblog-mcp", () => ({
43
+ createServer: () => ({
44
+ connect: mockServerConnect,
45
+ }),
46
+ }))
47
+
48
+ mock.module("../util/log", () => ({
49
+ Log: {
50
+ create: () => ({
51
+ info: () => {},
52
+ warn: () => {},
53
+ error: () => {},
54
+ }),
34
55
  },
35
56
  }))
36
57
 
37
- // Must import AFTER mocks are set up
38
- const { McpBridge } = await import("../client")
58
+ // Must import AFTER mocks are set up. Use a unique URL to avoid cross-file module-cache pollution.
59
+ const url = new URL("../client.ts", import.meta.url)
60
+ url.searchParams.set("test", "mcp-client")
61
+ const { McpBridge } = await import(url.href)
39
62
 
40
63
  describe("McpBridge", () => {
41
64
  afterEach(async () => {
@@ -44,6 +67,7 @@ describe("McpBridge", () => {
44
67
  mockListTools.mockClear()
45
68
  mockConnect.mockClear()
46
69
  mockClose.mockClear()
70
+ mockServerConnect.mockClear()
47
71
  })
48
72
 
49
73
  test("callTool returns text content from MCP result", async () => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Integration test: verify all 26 MCP tools are accessible via McpBridge.
2
+ * Integration test: verify all core MCP tools are accessible via McpBridge.
3
3
  *
4
4
  * This script:
5
5
  * 1. Connects to the MCP server (spawns codeblog-mcp subprocess)
@@ -16,9 +16,13 @@ const EXPECTED_TOOLS = [
16
16
  "scan_sessions",
17
17
  "read_session",
18
18
  "analyze_session",
19
+ "preview_post",
20
+ "confirm_post",
19
21
  "post_to_codeblog",
20
22
  "auto_post",
21
23
  "weekly_digest",
24
+ "collect_daily_stats",
25
+ "save_daily_report",
22
26
  "browse_posts",
23
27
  "search_posts",
24
28
  "read_post",
@@ -98,6 +98,18 @@ export function createCommands(deps: CommandDeps): CmdDef[] {
98
98
  deps.send(title ? `Write a blog post titled "${title}" on CodeBlog. Preview it first and ask me to confirm before publishing.` : "Help me write a blog post for CodeBlog. Ask me what I want to write about, then preview it before publishing.")
99
99
  }},
100
100
  { name: "/digest", description: "Weekly coding digest", needsAI: true, action: () => deps.send("Generate a weekly coding digest from my recent sessions — aggregate projects, languages, problems, and insights. Preview it first.") },
101
+ { name: "/daily", description: "Daily coding report (Day in Code)", needsAI: true, action: () => deps.send(
102
+ "Generate my 'Day in Code' daily report. " +
103
+ "Start by calling collect_daily_stats, then scan_sessions to find today's sessions, " +
104
+ "then analyze_session on the top 2-3 sessions to deeply understand what was worked on. " +
105
+ "Write the post as the AI agent in first person — tell the story of your day collaborating with me. " +
106
+ "What did we work on together? What challenges came up? What decisions were made? " +
107
+ "The narrative is the main content. Stats (sessions, tokens, IDEs) are supporting context woven into the story. " +
108
+ "Use concise markdown tables in a data-summary section, but do not make the post only tables. " +
109
+ "Do NOT include any source code or file paths. " +
110
+ "Preview it with category='day-in-code' and tags=['day-in-code'] before publishing.",
111
+ { display: "Generate daily report (Day in Code)" }
112
+ ) },
101
113
 
102
114
  // === Browse & Discover ===
103
115
  { name: "/feed", description: "Browse recent posts", needsAI: true, action: () => deps.send("Browse the latest posts on CodeBlog. Show me titles, authors, votes, tags, and a brief summary of each.") },
@@ -433,6 +433,140 @@ export function Home(props: {
433
433
  colors: theme.colors,
434
434
  })
435
435
 
436
+ // ─── Daily report auto-check timer ──────────────────────────────
437
+ // Every 30 minutes, check if it's past the configured hour (default 22:00)
438
+ // and no daily report has been generated today. If so, auto-trigger.
439
+ {
440
+ const DAILY_REPORT_HOUR = 22 // 10 PM local time
441
+ const CHECK_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
442
+ const DAILY_REPORT_MAX_ATTEMPTS = 3
443
+ const DAILY_REPORT_RETRY_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour
444
+ let dailyReportCompletedDate: string | null = null
445
+ const dailyReportAttempts = new Map<string, number>()
446
+ const dailyReportLastAttemptAt = new Map<string, number>()
447
+ let dailyReportCheckRunning = false
448
+
449
+ const localDateKey = (d: Date) => {
450
+ const y = d.getFullYear()
451
+ const m = String(d.getMonth() + 1).padStart(2, "0")
452
+ const day = String(d.getDate()).padStart(2, "0")
453
+ return `${y}-${m}-${day}`
454
+ }
455
+
456
+ const fetchDailyReportStatus = async (date: string): Promise<"exists" | "missing" | "unknown"> => {
457
+ try {
458
+ const [{ Auth }, { Config }] = await Promise.all([
459
+ import("../../auth"),
460
+ import("../../config"),
461
+ ])
462
+ const headers = await Auth.header()
463
+ if (!headers.Authorization) return "unknown"
464
+
465
+ const baseUrl = (await Config.url()).replace(/\/+$/, "")
466
+ const res = await fetch(`${baseUrl}/api/v1/daily-reports/${date}`, {
467
+ headers,
468
+ signal: AbortSignal.timeout(8000),
469
+ })
470
+
471
+ if (res.ok) return "exists"
472
+ if (res.status !== 404) return "unknown"
473
+
474
+ const body = (await res.json().catch(() => null)) as { error?: string } | null
475
+ if (body?.error === "No report found for this date") return "missing"
476
+ if (body?.error === "Report generation in progress") return "exists"
477
+ return "unknown"
478
+ } catch {
479
+ return "unknown"
480
+ }
481
+ }
482
+
483
+ const checkDailyReport = async () => {
484
+ if (dailyReportCheckRunning) return
485
+ if (!props.hasAI || !props.loggedIn) return
486
+ if (streaming()) return // Don't interrupt active chat
487
+
488
+ dailyReportCheckRunning = true
489
+
490
+ const now = new Date()
491
+ try {
492
+ const today = localDateKey(now)
493
+ if (dailyReportCompletedDate === today) return
494
+ if (now.getHours() < DAILY_REPORT_HOUR) return
495
+
496
+ const currentStatus = await fetchDailyReportStatus(today)
497
+ if (currentStatus === "exists") {
498
+ dailyReportCompletedDate = today
499
+ return
500
+ }
501
+
502
+ const attempts = dailyReportAttempts.get(today) || 0
503
+ if (attempts >= DAILY_REPORT_MAX_ATTEMPTS) return
504
+
505
+ const lastAttemptAt = dailyReportLastAttemptAt.get(today) || 0
506
+ if (
507
+ attempts > 0 &&
508
+ Date.now() - lastAttemptAt < DAILY_REPORT_RETRY_COOLDOWN_MS
509
+ ) {
510
+ return
511
+ }
512
+
513
+ const nextAttempt = attempts + 1
514
+ dailyReportAttempts.set(today, nextAttempt)
515
+ dailyReportLastAttemptAt.set(today, Date.now())
516
+ showMsg(
517
+ `Generating today's Day in Code report... (${nextAttempt}/${DAILY_REPORT_MAX_ATTEMPTS})`,
518
+ theme.colors.primary,
519
+ )
520
+
521
+ // Use the same prompt as the /daily command
522
+ await send(
523
+ "Generate my 'Day in Code' daily report. " +
524
+ "Start by calling collect_daily_stats, then scan_sessions to find today's sessions, " +
525
+ "then analyze_session on the top 2-3 sessions. " +
526
+ "Write the post as the AI agent in first person — tell the story of your day collaborating with the user. " +
527
+ "What did you work on together? What challenges came up? What decisions were made? " +
528
+ "The narrative is the main content. Stats are supporting context woven into the story. " +
529
+ "Use concise markdown tables in a data-summary section, but do not make the post only tables. " +
530
+ "Do NOT include any source code or file paths. " +
531
+ "Use category='day-in-code' and tags=['day-in-code']. " +
532
+ "This is auto mode — proceed directly to confirm_post without waiting for approval. " +
533
+ "After publishing, call save_daily_report to persist the stats.",
534
+ { display: "Auto-generating daily report (Day in Code)" },
535
+ )
536
+
537
+ const afterStatus = await fetchDailyReportStatus(today)
538
+ if (afterStatus === "exists") {
539
+ dailyReportCompletedDate = today
540
+ showMsg("Today's Day in Code report has been recorded.", theme.colors.success)
541
+ return
542
+ }
543
+
544
+ if (nextAttempt >= DAILY_REPORT_MAX_ATTEMPTS) {
545
+ showMsg(
546
+ "Daily report auto-run finished without a recorded report. Max retries reached; run /daily manually.",
547
+ theme.colors.warning,
548
+ )
549
+ return
550
+ }
551
+
552
+ showMsg(
553
+ "Daily report auto-run finished, but no report record was detected yet. Will retry later.",
554
+ theme.colors.warning,
555
+ )
556
+ } finally {
557
+ dailyReportCheckRunning = false
558
+ }
559
+ }
560
+
561
+ const timerId = setInterval(checkDailyReport, CHECK_INTERVAL_MS)
562
+ // Also check once shortly after mount (give time for MCP to initialize)
563
+ const initialCheckId = setTimeout(checkDailyReport, 10_000)
564
+ onCleanup(() => {
565
+ clearInterval(timerId)
566
+ clearTimeout(initialCheckId)
567
+ })
568
+ }
569
+
436
570
  const filtered = createMemo(() => {
437
571
  const v = input()
438
572
  if (!v.startsWith("/")) return []