Commit eb247605b26f46c046fcb758ab1fc0dd1044f63a

Authored by yibo
1 parent 44c68767

feat(goal): P2 #6/#7/#8 — Goal OS 地基 + AI 工具

P2 #6 Goal 实体(DB v23→v24):
- goals 表:id/text/status/deadline/parentGoalId/personId/notes/createdAt/updatedAt
- status: active/paused/done/cancelled
- parentGoalId 支持树形分解(创业→融资→找VC→约张总)
- GoalDao:insert/get/all/observeAll/observeByStatus/roots/children/updateStatus/updateNotes/delete

P2 #7 Followup→Goal 挂载:
- followups 表加 goalId 列
- FollowupDao 加 updateGoal / observeByGoal / all(一次性查,供 LLM 文本匹配)
- FollowupRow 加 goalId 字段

P2 #8 AI Goal 工具(4 个):
- create_goal:建目标,支持截止日期/父目标/关联人物
- get_goals:树形展示目标+子目标+已挂待跟进,支持状态过滤
- link_followup_to_goal:按 id 或关键词匹配,批量关联 followup→goal
- update_goal_status:标完成/暂停/重启/放弃

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt
@@ -312,15 +312,21 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { @@ -312,15 +312,21 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
312 【关系网络 & 组织】 312 【关系网络 & 组织】
313 ⑲ get_stale_contacts — 列出长期未联系的人(args: min_days默认30, limit默认20) 313 ⑲ get_stale_contacts — 列出长期未联系的人(args: min_days默认30, limit默认20)
314 ⑳ get_org_members — 查某组织的成员列表(args: org_name) 314 ⑳ get_org_members — 查某组织的成员列表(args: org_name)
315 - ㉑ link_person_org — 将人物关联到组织/公司(args: person_name, org_name, kind可选company|school|org|project, role可选, start_date可选如2023-01, end_date可选) 315 + ㉑ link_person_org — 将人物关联到组织/公司(args: person_name, org_name, kind可选, role可选, start_date可选)
316 ㉒ find_relevant_persons — 根据目标找最相关的人「谁能帮我」(args: goal, limit默认10) 316 ㉒ find_relevant_persons — 根据目标找最相关的人「谁能帮我」(args: goal, limit默认10)
317 317
  318 + 【目标管理 Goal OS】
  319 + ㉓ create_goal — 创建目标(args: text, deadline可选如2026-09-01, parent_goal_id可选, person_name可选, notes可选)
  320 + ㉔ get_goals — 查目标列表,含子目标树+关联待跟进(args: status=active|paused|done|cancelled|all,默认active)
  321 + ㉕ link_followup_to_goal — 把待跟进事项挂到目标(args: goal_id, followup_id或followup_text)
  322 + ㉖ update_goal_status — 更新目标状态(args: goal_id, status=active|paused|done|cancelled)
  323 +
318 【数据刷新】 324 【数据刷新】
319 - ㉓ refresh_person_data — 刷新某人微信聊天+朋友圈(args: person_name) 325 + ㉗ refresh_person_data — 刷新某人微信聊天+朋友圈(args: person_name)
320 ★ 查询某人聊天/朋友圈/时间线前,必须先调此工具确保数据最新! 326 ★ 查询某人聊天/朋友圈/时间线前,必须先调此工具确保数据最新!
321 327
322 【全网搜索】 328 【全网搜索】
323 - ㉔ web_search — Tavily 搜索互联网(args: query, max_results默认5;需设置中配置 Key) 329 + ㉘ web_search — Tavily 搜索互联网(args: query, max_results默认5;需设置中配置 Key)
324 330
325 需要查询时,输出(每个工具独占一行,严格 JSON): 331 需要查询时,输出(每个工具独占一行,严格 JSON):
326 TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}} 332 TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}}
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt
@@ -264,6 +264,45 @@ object LifelogTools { @@ -264,6 +264,45 @@ object LifelogTools {
264 ), 264 ),
265 required = listOf("goal"), 265 required = listOf("goal"),
266 )) 266 ))
  267 + put(schema(
  268 + name = "create_goal",
  269 + desc = "创建一个新目标(支持树形分解:可指定父目标 + 关联人物)",
  270 + props = mapOf(
  271 + "text" to prop("string", "目标内容,如「完成融资 A 轮」"),
  272 + "deadline" to prop("string", "截止日期 ISO 格式如 2026-09-01,可选"),
  273 + "parent_goal_id" to prop("integer", "父目标 id(子目标用),可选"),
  274 + "person_name" to prop("string", "关联人物姓名,可选"),
  275 + "notes" to prop("string", "备注,可选"),
  276 + ),
  277 + required = listOf("text"),
  278 + ))
  279 + put(schema(
  280 + name = "get_goals",
  281 + desc = "查看目标列表(支持按状态过滤,默认返回活跃目标;展示目标树结构 + 关联的待跟进事项)",
  282 + props = mapOf(
  283 + "status" to prop("string", "状态过滤:active(活跃)/ paused(暂停)/ done(完成)/ cancelled(放弃)/ all(全部),默认 active"),
  284 + ),
  285 + required = emptyList(),
  286 + ))
  287 + put(schema(
  288 + name = "link_followup_to_goal",
  289 + desc = "将一条待跟进事项(Followup)关联到某个目标,支持按 id 或关键词匹配",
  290 + props = mapOf(
  291 + "goal_id" to prop("integer", "目标 id(从 get_goals 获取)"),
  292 + "followup_id" to prop("integer", "待跟进事项 id(与 followup_text 二选一)"),
  293 + "followup_text" to prop("string", "待跟进事项关键词(模糊匹配,与 followup_id 二选一)"),
  294 + ),
  295 + required = listOf("goal_id"),
  296 + ))
  297 + put(schema(
  298 + name = "update_goal_status",
  299 + desc = "更新目标状态(完成/暂停/重启/放弃)",
  300 + props = mapOf(
  301 + "goal_id" to prop("integer", "目标 id"),
  302 + "status" to prop("string", "新状态:active / paused / done / cancelled"),
  303 + ),
  304 + required = listOf("goal_id", "status"),
  305 + ))
267 } 306 }
268 } 307 }
269 308
@@ -314,6 +353,10 @@ object LifelogTools { @@ -314,6 +353,10 @@ object LifelogTools {
314 "get_org_members" -> getOrgMembers(repo, args) 353 "get_org_members" -> getOrgMembers(repo, args)
315 "link_person_org" -> linkPersonOrg(repo, args) 354 "link_person_org" -> linkPersonOrg(repo, args)
316 "find_relevant_persons" -> findRelevantPersons(repo, args) 355 "find_relevant_persons" -> findRelevantPersons(repo, args)
  356 + "create_goal" -> createGoal(repo, args)
  357 + "get_goals" -> getGoals(repo, args)
  358 + "link_followup_to_goal" -> linkFollowupToGoal(repo, args)
  359 + "update_goal_status" -> updateGoalStatus(repo, args)
317 else -> "未知工具:$name" 360 else -> "未知工具:$name"
318 } 361 }
319 } catch (t: Throwable) { 362 } catch (t: Throwable) {
@@ -958,6 +1001,132 @@ object LifelogTools { @@ -958,6 +1001,132 @@ object LifelogTools {
958 } 1001 }
959 } 1002 }
960 1003
  1004 + // ── P2 #8 Goal OS 工具 ────────────────────────────────────────────────────
  1005 +
  1006 + private suspend fun createGoal(repo: Repository, args: JSONObject): String {
  1007 + val text = args.optString("text", "").ifBlank { return "请提供目标内容" }
  1008 + val deadline = args.optString("deadline", "").ifBlank { null }
  1009 + val parentId = args.optLong("parent_goal_id", 0).takeIf { it > 0 }
  1010 + val personName = args.optString("person_name", "").ifBlank { null }
  1011 + val notes = args.optString("notes", "").ifBlank { null }
  1012 +
  1013 + // 可选关联人物
  1014 + val personId = personName?.let { q ->
  1015 + repo.persons.all().firstOrNull { p ->
  1016 + p.name.contains(q) || p.realName?.contains(q) == true || p.nickname?.contains(q) == true
  1017 + }?.id
  1018 + }
  1019 +
  1020 + // 校验父目标存在
  1021 + if (parentId != null && repo.goals.get(parentId) == null)
  1022 + return "找不到父目标 id=$parentId,请先用 get_goals 确认 id。"
  1023 +
  1024 + val now = System.currentTimeMillis()
  1025 + val id = repo.goals.insert(
  1026 + com.lemon.lifelog.data.Goal(
  1027 + text = text,
  1028 + deadline = deadline,
  1029 + parentGoalId = parentId,
  1030 + personId = personId,
  1031 + notes = notes,
  1032 + createdAt = now,
  1033 + updatedAt = now,
  1034 + )
  1035 + )
  1036 + val parentLabel = if (parentId != null) repo.goals.get(parentId)?.text?.take(30)?.let { "「$it」的子目标" } ?: "" else ""
  1037 + val deadlineStr = deadline?.let { ",截止 $it" } ?: ""
  1038 + val personStr = personId?.let { repo.persons.get(it)?.name?.let { n -> ",关联:$n" } } ?: ""
  1039 + return "✅ 目标已创建(id=$id):${text}${deadlineStr}${personStr}${if (parentLabel.isNotBlank()) "\n→ $parentLabel" else ""}"
  1040 + }
  1041 +
  1042 + private suspend fun getGoals(repo: Repository, args: JSONObject): String {
  1043 + val statusArg = args.optString("status", "active").ifBlank { "active" }
  1044 + val allGoals = if (statusArg == "all") repo.goals.all()
  1045 + else repo.goals.all().filter { it.status == statusArg }
  1046 + if (allGoals.isEmpty()) return "暂无「$statusArg」状态的目标。"
  1047 +
  1048 + val allFollowups = repo.followups.all()
  1049 + val followupsByGoal = allFollowups.groupBy { it.goalId }
  1050 + val personMap = repo.persons.all().associate { it.id to (it.realName ?: it.nickname ?: it.name) }
  1051 +
  1052 + val statusLabel = mapOf("active" to "活跃", "paused" to "暂停", "done" to "完成", "cancelled" to "放弃")
  1053 +
  1054 + // 树形输出:先输出根目标,再缩进子目标
  1055 + val rootGoals = allGoals.filter { it.parentGoalId == null }
  1056 + val childMap = allGoals.filter { it.parentGoalId != null }.groupBy { it.parentGoalId }
  1057 +
  1058 + fun goalBlock(g: com.lemon.lifelog.data.Goal, indent: String): String = buildString {
  1059 + val status = statusLabel[g.status] ?: g.status
  1060 + val dl = g.deadline?.let { " 截止$it" } ?: ""
  1061 + val who = g.personId?.let { personMap[it]?.let { n -> " 关联:$n" } } ?: ""
  1062 + appendLine("${indent}[${g.id}] $status ◆ ${g.text}$dl$who")
  1063 + g.notes?.takeIf { it.isNotBlank() }?.let { appendLine("${indent} 备注:$it") }
  1064 + val followups = followupsByGoal[g.id]
  1065 + if (!followups.isNullOrEmpty()) {
  1066 + val open = followups.filter { !it.done }
  1067 + val done = followups.filter { it.done }
  1068 + if (open.isNotEmpty()) open.forEach { f -> appendLine("${indent} ○ ${f.text}") }
  1069 + if (done.isNotEmpty()) done.take(3).forEach { f -> appendLine("${indent} ✓ ${f.text}") }
  1070 + }
  1071 + }
  1072 +
  1073 + return buildString {
  1074 + appendLine("目标列表(状态:$statusArg,共 ${allGoals.size} 条):")
  1075 + rootGoals.forEach { root ->
  1076 + append(goalBlock(root, ""))
  1077 + childMap[root.id]?.forEach { child ->
  1078 + append(goalBlock(child, " └ "))
  1079 + childMap[child.id]?.forEach { grandchild ->
  1080 + append(goalBlock(grandchild, " └ "))
  1081 + }
  1082 + }
  1083 + }
  1084 + // 有父目标但父不在当前筛选结果里的孤儿目标
  1085 + val orphans = allGoals.filter { it.parentGoalId != null && rootGoals.none { r -> r.id == it.parentGoalId } && !rootGoals.any { r -> childMap[r.id]?.any { c -> c.id == it.parentGoalId } == true } }
  1086 + if (orphans.isNotEmpty()) {
  1087 + appendLine("\n(其他子目标)")
  1088 + orphans.forEach { append(goalBlock(it, " ")) }
  1089 + }
  1090 + }
  1091 + }
  1092 +
  1093 + private suspend fun linkFollowupToGoal(repo: Repository, args: JSONObject): String {
  1094 + val goalId = args.optLong("goal_id", 0).takeIf { it > 0 }
  1095 + ?: return "请提供 goal_id(从 get_goals 获取)"
  1096 + val goal = repo.goals.get(goalId) ?: return "找不到目标 id=$goalId"
  1097 +
  1098 + val followupId = args.optLong("followup_id", 0).takeIf { it > 0 }
  1099 + val followupText = args.optString("followup_text", "").ifBlank { null }
  1100 +
  1101 + if (followupId == null && followupText == null)
  1102 + return "请提供 followup_id 或 followup_text"
  1103 +
  1104 + val allFollowups = repo.followups.all()
  1105 + val targets: List<com.lemon.lifelog.data.FollowupRow> = when {
  1106 + followupId != null -> allFollowups.filter { it.id == followupId }
  1107 + else -> allFollowups.filter { it.text.contains(followupText!!, ignoreCase = true) }.take(5)
  1108 + }
  1109 + if (targets.isEmpty()) return "找不到匹配的待跟进事项(关键词:「${followupText ?: followupId}」)"
  1110 +
  1111 + targets.forEach { repo.followups.updateGoal(it.id, goalId) }
  1112 + return buildString {
  1113 + appendLine("✅ 已关联 ${targets.size} 条待跟进事项 → 目标「${goal.text}」:")
  1114 + targets.forEach { appendLine(" - ${it.text}") }
  1115 + }
  1116 + }
  1117 +
  1118 + private suspend fun updateGoalStatus(repo: Repository, args: JSONObject): String {
  1119 + val goalId = args.optLong("goal_id", 0).takeIf { it > 0 }
  1120 + ?: return "请提供 goal_id"
  1121 + val status = args.optString("status", "").ifBlank { return "请提供状态:active / paused / done / cancelled" }
  1122 + val valid = setOf("active", "paused", "done", "cancelled")
  1123 + if (status !in valid) return "无效状态「$status」,可选:${valid.joinToString(" / ")}"
  1124 + val goal = repo.goals.get(goalId) ?: return "找不到目标 id=$goalId"
  1125 + repo.goals.updateStatus(goalId, status, System.currentTimeMillis())
  1126 + val label = mapOf("active" to "重新激活", "paused" to "暂停", "done" to "标为完成 ✅", "cancelled" to "放弃")
  1127 + return "目标「${goal.text}」已${label[status]}。"
  1128 + }
  1129 +
961 // ── 人物时间线 ─────────────────────────────────────────────────────────── 1130 // ── 人物时间线 ───────────────────────────────────────────────────────────
962 1131
963 private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { 1132 private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String {
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
@@ -29,8 +29,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase @@ -29,8 +29,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
29 TopicMention::class, 29 TopicMention::class,
30 Organization::class, 30 Organization::class,
31 PersonOrg::class, 31 PersonOrg::class,
  32 + Goal::class,
32 ], 33 ],
33 - version = 23, 34 + version = 24,
34 exportSchema = false, 35 exportSchema = false,
35 ) 36 )
36 abstract class AppDatabase : RoomDatabase() { 37 abstract class AppDatabase : RoomDatabase() {
@@ -50,6 +51,7 @@ abstract class AppDatabase : RoomDatabase() { @@ -50,6 +51,7 @@ abstract class AppDatabase : RoomDatabase() {
50 abstract fun topicDao(): TopicDao 51 abstract fun topicDao(): TopicDao
51 abstract fun organizationDao(): OrganizationDao 52 abstract fun organizationDao(): OrganizationDao
52 abstract fun personOrgDao(): PersonOrgDao 53 abstract fun personOrgDao(): PersonOrgDao
  54 + abstract fun goalDao(): GoalDao
53 55
54 companion object { 56 companion object {
55 @Volatile 57 @Volatile
@@ -252,6 +254,26 @@ abstract class AppDatabase : RoomDatabase() { @@ -252,6 +254,26 @@ abstract class AppDatabase : RoomDatabase() {
252 } 254 }
253 } 255 }
254 256
  257 + /** P2 #6/#7:goals 新表 + followups.goalId 挂载字段。 */
  258 + private val MIGRATION_23_24 = object : Migration(23, 24) {
  259 + override fun migrate(db: SupportSQLiteDatabase) {
  260 + db.execSQL(
  261 + "CREATE TABLE IF NOT EXISTS `goals` (" +
  262 + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
  263 + "`text` TEXT NOT NULL, " +
  264 + "`status` TEXT NOT NULL DEFAULT 'active', " +
  265 + "`deadline` TEXT, " +
  266 + "`parentGoalId` INTEGER, " +
  267 + "`personId` INTEGER, " +
  268 + "`notes` TEXT, " +
  269 + "`createdAt` INTEGER NOT NULL, " +
  270 + "`updatedAt` INTEGER NOT NULL)"
  271 + )
  272 + db.execSQL("CREATE INDEX IF NOT EXISTS `index_goals_status` ON `goals` (`status`)")
  273 + db.execSQL("ALTER TABLE `followups` ADD COLUMN `goalId` INTEGER")
  274 + }
  275 + }
  276 +
255 fun get(ctx: Context): AppDatabase = 277 fun get(ctx: Context): AppDatabase =
256 instance ?: synchronized(this) { 278 instance ?: synchronized(this) {
257 instance ?: Room.databaseBuilder( 279 instance ?: Room.databaseBuilder(
@@ -259,7 +281,7 @@ abstract class AppDatabase : RoomDatabase() { @@ -259,7 +281,7 @@ abstract class AppDatabase : RoomDatabase() {
259 AppDatabase::class.java, 281 AppDatabase::class.java,
260 "lifelog.db", 282 "lifelog.db",
261 ) 283 )
262 - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23) 284 + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24)
263 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, 285 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
264 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 286 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
265 .build() 287 .build()
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
@@ -399,6 +399,7 @@ data class FollowupRow( @@ -399,6 +399,7 @@ data class FollowupRow(
399 val personName: String?, 399 val personName: String?,
400 val sourceTs: Long, 400 val sourceTs: Long,
401 val done: Boolean, 401 val done: Boolean,
  402 + val goalId: Long? = null,
402 ) 403 )
403 404
404 @Dao 405 @Dao
@@ -409,6 +410,9 @@ interface FollowupDao { @@ -409,6 +410,9 @@ interface FollowupDao {
409 @Query("UPDATE followups SET done = :done WHERE id = :id") 410 @Query("UPDATE followups SET done = :done WHERE id = :id")
410 suspend fun setDone(id: Long, done: Boolean) 411 suspend fun setDone(id: Long, done: Boolean)
411 412
  413 + @Query("UPDATE followups SET goalId = :goalId WHERE id = :id")
  414 + suspend fun updateGoal(id: Long, goalId: Long?)
  415 +
412 @Query("DELETE FROM followups WHERE id = :id") 416 @Query("DELETE FROM followups WHERE id = :id")
413 suspend fun delete(id: Long) 417 suspend fun delete(id: Long)
414 418
@@ -418,14 +422,66 @@ interface FollowupDao { @@ -418,14 +422,66 @@ interface FollowupDao {
418 /** 未完成在前、按来源时间倒序。 */ 422 /** 未完成在前、按来源时间倒序。 */
419 @Query( 423 @Query(
420 """ 424 """
421 - SELECT f.id AS id, f.text AS text, f.personId AS personId, p.name AS personName,  
422 - f.sourceTs AS sourceTs, f.done AS done 425 + SELECT f.id, f.text, f.personId, p.name AS personName,
  426 + f.sourceTs, f.done, f.goalId
423 FROM followups f 427 FROM followups f
424 LEFT JOIN persons p ON p.id = f.personId 428 LEFT JOIN persons p ON p.id = f.personId
425 ORDER BY f.done ASC, f.sourceTs DESC 429 ORDER BY f.done ASC, f.sourceTs DESC
426 """, 430 """,
427 ) 431 )
428 fun observeAll(): Flow<List<FollowupRow>> 432 fun observeAll(): Flow<List<FollowupRow>>
  433 +
  434 + /** 挂在某个目标下的待跟进事项。 */
  435 + @Query(
  436 + """
  437 + SELECT f.id, f.text, f.personId, p.name AS personName,
  438 + f.sourceTs, f.done, f.goalId
  439 + FROM followups f
  440 + LEFT JOIN persons p ON p.id = f.personId
  441 + WHERE f.goalId = :goalId
  442 + ORDER BY f.done ASC, f.sourceTs DESC
  443 + """,
  444 + )
  445 + fun observeByGoal(goalId: Long): Flow<List<FollowupRow>>
  446 +
  447 + /** 一次性取,供 LLM 工具匹配文本。 */
  448 + @Query("SELECT f.id, f.text, f.personId, p.name AS personName, f.sourceTs, f.done, f.goalId FROM followups f LEFT JOIN persons p ON p.id = f.personId ORDER BY f.done ASC, f.sourceTs DESC")
  449 + suspend fun all(): List<FollowupRow>
  450 +}
  451 +
  452 +// ── Goal OS ───────────────────────────────────────────────────────────────────
  453 +
  454 +@Dao
  455 +interface GoalDao {
  456 + @Insert(onConflict = OnConflictStrategy.REPLACE)
  457 + suspend fun insert(g: Goal): Long
  458 +
  459 + @Query("SELECT * FROM goals ORDER BY status ASC, createdAt DESC")
  460 + fun observeAll(): Flow<List<Goal>>
  461 +
  462 + @Query("SELECT * FROM goals WHERE status = :status ORDER BY createdAt DESC")
  463 + fun observeByStatus(status: String): Flow<List<Goal>>
  464 +
  465 + @Query("SELECT * FROM goals WHERE id = :id")
  466 + suspend fun get(id: Long): Goal?
  467 +
  468 + @Query("SELECT * FROM goals WHERE parentGoalId IS NULL ORDER BY status ASC, createdAt DESC")
  469 + suspend fun roots(): List<Goal>
  470 +
  471 + @Query("SELECT * FROM goals WHERE parentGoalId = :parentId ORDER BY createdAt DESC")
  472 + suspend fun children(parentId: Long): List<Goal>
  473 +
  474 + @Query("UPDATE goals SET status = :status, updatedAt = :ts WHERE id = :id")
  475 + suspend fun updateStatus(id: Long, status: String, ts: Long)
  476 +
  477 + @Query("UPDATE goals SET notes = :notes, updatedAt = :ts WHERE id = :id")
  478 + suspend fun updateNotes(id: Long, notes: String?, ts: Long)
  479 +
  480 + @Query("SELECT * FROM goals ORDER BY status ASC, createdAt DESC")
  481 + suspend fun all(): List<Goal>
  482 +
  483 + @Query("DELETE FROM goals WHERE id = :id")
  484 + suspend fun delete(id: Long)
429 } 485 }
430 486
431 /** 话题行(含被提到次数 + 最近时间)。 */ 487 /** 话题行(含被提到次数 + 最近时间)。 */
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
@@ -230,6 +230,8 @@ data class Followup( @@ -230,6 +230,8 @@ data class Followup(
230 val text: String, 230 val text: String,
231 /** 关联人物(可空,从文本匹配 Person) */ 231 /** 关联人物(可空,从文本匹配 Person) */
232 val personId: Long? = null, 232 val personId: Long? = null,
  233 + /** 挂载到的目标(可空,由 AI 或用户手动关联) */
  234 + val goalId: Long? = null,
233 /** 来源录音时间(窗口内最新段的墙钟) */ 235 /** 来源录音时间(窗口内最新段的墙钟) */
234 val sourceTs: Long, 236 val sourceTs: Long,
235 val done: Boolean = false, 237 val done: Boolean = false,
@@ -237,6 +239,28 @@ data class Followup( @@ -237,6 +239,28 @@ data class Followup(
237 ) 239 )
238 240
239 /** 241 /**
  242 + * Goal OS 目标实体。支持树形分解([parentGoalId] 非空 = 子目标)。
  243 + * [status]:active / paused / done / cancelled。
  244 + */
  245 +@Entity(tableName = "goals")
  246 +data class Goal(
  247 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
  248 + val text: String,
  249 + /** active / paused / done / cancelled */
  250 + val status: String = "active",
  251 + /** 截止日期,ISO 日期字符串如 "2026-09-01",可空 */
  252 + val deadline: String? = null,
  253 + /** 父目标 id(非空 = 子目标,支持树形分解) */
  254 + val parentGoalId: Long? = null,
  255 + /** 与哪个人相关(可空) */
  256 + val personId: Long? = null,
  257 + /** 备注(AI 或用户填写) */
  258 + val notes: String? = null,
  259 + val createdAt: Long = System.currentTimeMillis(),
  260 + val updatedAt: Long = System.currentTimeMillis(),
  261 +)
  262 +
  263 +/**
240 * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) 264 * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions)
241 * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。 265 * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。
242 */ 266 */
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt
@@ -20,4 +20,5 @@ class Repository(ctx: Context) { @@ -20,4 +20,5 @@ class Repository(ctx: Context) {
20 val topics = db.topicDao() 20 val topics = db.topicDao()
21 val organizations = db.organizationDao() 21 val organizations = db.organizationDao()
22 val personOrgs = db.personOrgDao() 22 val personOrgs = db.personOrgDao()
  23 + val goals = db.goalDao()
23 } 24 }