@@ -760,14 +760,26 @@ async def search_notes(
760760 content_snippet = ''
761761 if note .data and note .data .get ('content' , {}).get ('md' ):
762762 md_content = note .data ['content' ]['md' ]
763- lower_content = md_content .lower ()
764- lower_query = query .lower ()
765- idx = lower_content .find (lower_query )
766- if idx != - 1 :
767- start = max (0 , idx - 50 )
768- end = min (len (md_content ), idx + len (query ) + 100 )
763+ content_lower = md_content .lower ()
764+
765+ # Find the first matching word to center the snippet around.
766+ search_words = query .lower ().split ()
767+ match_pos = - 1
768+ match_len = len (query )
769+ for word in search_words :
770+ found_pos = content_lower .find (word )
771+ if found_pos != - 1 :
772+ match_pos = found_pos
773+ match_len = len (word )
774+ break
775+
776+ if match_pos != - 1 :
777+ snippet_start = max (0 , match_pos - 50 )
778+ snippet_end = min (len (md_content ), match_pos + match_len + 100 )
769779 content_snippet = (
770- ('...' if start > 0 else '' ) + md_content [start :end ] + ('...' if end < len (md_content ) else '' )
780+ ('...' if snippet_start > 0 else '' )
781+ + md_content [snippet_start :snippet_end ]
782+ + ('...' if snippet_end < len (md_content ) else '' )
771783 )
772784 else :
773785 content_snippet = md_content [:150 ] + ('...' if len (md_content ) > 150 else '' )
@@ -2337,162 +2349,137 @@ async def view_skill(
23372349
23382350class TaskItem (BaseModel ):
23392351 id : Optional [str ] = Field (None , description = 'Unique identifier for the task. Auto-generated if omitted.' )
2340- content : Optional [ str ] = Field (None , description = 'Task description. Aliases: title, name, description.' )
2352+ content : str = Field (... , description = 'Task description.' )
23412353 status : Literal ['pending' , 'in_progress' , 'completed' , 'cancelled' ] = Field ('pending' , description = 'Task status.' )
23422354
23432355
2344- async def tasks (
2345- tasks : Optional [list [TaskItem ]] = None ,
2346- overwrite : bool = True ,
2356+ def _task_summary (all_tasks : list [dict ]) -> dict :
2357+ """Build summary counts for a task list."""
2358+ pending = sum (1 for t in all_tasks if t ['status' ] == 'pending' )
2359+ in_progress = sum (1 for t in all_tasks if t ['status' ] == 'in_progress' )
2360+ completed = sum (1 for t in all_tasks if t ['status' ] == 'completed' )
2361+ cancelled = sum (1 for t in all_tasks if t ['status' ] == 'cancelled' )
2362+ return {
2363+ 'total' : len (all_tasks ),
2364+ 'pending' : pending ,
2365+ 'in_progress' : in_progress ,
2366+ 'completed' : completed ,
2367+ 'cancelled' : cancelled ,
2368+ }
2369+
2370+
2371+ async def _emit_tasks (event_emitter , all_tasks : list [dict ]):
2372+ """Persist task state to the UI."""
2373+ if event_emitter :
2374+ await event_emitter (
2375+ {
2376+ 'type' : 'chat:message:tasks' ,
2377+ 'data' : {
2378+ 'tasks' : all_tasks ,
2379+ },
2380+ }
2381+ )
2382+
2383+
2384+ async def create_tasks (
2385+ tasks : list [TaskItem ],
23472386 __chat_id__ : str = None ,
23482387 __message_id__ : str = None ,
23492388 __event_emitter__ : callable = None ,
23502389 __request__ : Request = None ,
23512390 __user__ : dict = None ,
23522391) -> str :
23532392 """
2354- Track progress on multi-step work by maintaining a task checklist.
2355- Use this whenever a request involves multiple steps or could take
2356- significant effort. Call to set the full list, then call again
2357- with overwrite=false after completing each task to mark it
2358- completed. Do not leave tasks in_progress when the work is done.
2359- Each task has an id, content, and status (pending, in_progress,
2360- completed, cancelled).
2393+ Create a task checklist to track progress on multi-step work.
2394+ Call this once at the start to define all steps, then use
2395+ update_task to mark each task as you complete it.
23612396
2362- :param tasks: Optional list of task items. Each item: id (string), content (string, required for new tasks), status (pending|in_progress|completed|cancelled). Leave empty to fetch without modifying.
2363- :param overwrite: If true (default), replaces the entire task list. If false, updates/adds tasks by id while keeping existing ones.
2397+ :param tasks: List of task items. Each item: content (string, required), status (pending|in_progress|completed|cancelled, default pending), id (optional, auto-generated).
23642398 :return: JSON with the full task list and summary counts
23652399 """
23662400 if __chat_id__ is None :
23672401 return json .dumps ({'error' : 'Chat context not available' })
23682402
23692403 try :
2370-
2371- def _to_dict (task ) -> dict :
2372- """Convert TaskItem or dict to plain dict."""
2404+ all_tasks = []
2405+ for idx , task in enumerate (tasks ):
23732406 if hasattr (task , 'model_dump' ):
23742407 d = task .model_dump (exclude_none = True )
2375- # Include any extra fields the model sent
2376- if hasattr (task , 'model_extra' ) and task .model_extra :
2377- d .update (task .model_extra )
2378- return d
2379- return dict (task ) if not isinstance (task , dict ) else task
2380-
2381- def _resolve_content (d : dict ) -> str :
2382- """Accept content, title, name, or description as the task text."""
2383- for key in ('content' , 'title' , 'name' , 'description' ):
2384- val = str (d .get (key , '' )).strip ()
2385- if val :
2386- return val
2387- return ''
2388-
2389- def _resolve_id (d : dict , idx : int ) -> str :
2390- """Use provided id, or auto-generate from index."""
2391- item_id = str (d .get ('id' , '' ) or '' ).strip ()
2392- return item_id if item_id else str (idx + 1 )
2393-
2394- if tasks is None :
2395- # Read-only - return current list
2396- all_tasks = await Chats .get_chat_tasks_by_id (__chat_id__ )
2397- elif overwrite :
2398- # Full replacement - validate and write
2399- all_tasks = []
2400- for idx , task in enumerate (tasks ):
2401- d = _to_dict (task )
2402- item_id = _resolve_id (d , idx )
2403- content = _resolve_content (d )
2404- if not content :
2405- continue
2408+ elif isinstance (task , dict ):
2409+ d = task
2410+ else :
2411+ d = dict (task )
24062412
2407- status = str (d .get ('status ' , 'pending ' )).strip (). lower ()
2408- if status not in VALID_TASK_STATUSES :
2409- status = 'pending'
2413+ content = str (d .get ('content ' , '' )).strip ()
2414+ if not content :
2415+ continue
24102416
2411- all_tasks .append (
2412- {
2413- 'id' : item_id ,
2414- 'content' : content ,
2415- 'status' : status ,
2416- }
2417- )
2418- else :
2419- # Partial update - merge by id
2420- existing_tasks = await Chats .get_chat_tasks_by_id (__chat_id__ )
2421- existing_by_id = {t ['id' ]: t for t in existing_tasks }
2422-
2423- seen_ids = set ()
2424- for idx , task in enumerate (tasks ):
2425- d = _to_dict (task )
2426- item_id = _resolve_id (d , len (existing_tasks ) + idx )
2427-
2428- seen_ids .add (item_id )
2429-
2430- if item_id in existing_by_id :
2431- resolved = _resolve_content (d )
2432- if resolved :
2433- existing_by_id [item_id ]['content' ] = resolved
2434- status = str (d .get ('status' , '' )).strip ().lower ()
2435- if status and status in VALID_TASK_STATUSES :
2436- existing_by_id [item_id ]['status' ] = status
2437- else :
2438- content = _resolve_content (d )
2439- if not content :
2440- continue
2417+ item_id = str (d .get ('id' , '' ) or '' ).strip () or str (idx + 1 )
2418+ status = str (d .get ('status' , 'pending' )).strip ().lower ()
2419+ if status not in VALID_TASK_STATUSES :
2420+ status = 'pending'
24412421
2442- status = str (d .get ('status' , 'pending' )).strip ().lower ()
2443- if status not in VALID_TASK_STATUSES :
2444- status = 'pending'
2422+ all_tasks .append ({'id' : item_id , 'content' : content , 'status' : status })
24452423
2446- existing_by_id [item_id ] = {
2447- 'id' : item_id ,
2448- 'content' : content ,
2449- 'status' : status ,
2450- }
2424+ await Chats .update_chat_tasks_by_id (__chat_id__ , all_tasks )
2425+ await _emit_tasks (__event_emitter__ , all_tasks )
24512426
2452- # Preserve order of existing, append new
2453- all_tasks = []
2454- for t in existing_tasks :
2455- if t ['id' ] in existing_by_id :
2456- all_tasks .append (existing_by_id [t ['id' ]])
2457- for item_id in seen_ids :
2458- if not any (t ['id' ] == item_id for t in existing_tasks ):
2459- all_tasks .append (existing_by_id [item_id ])
2460-
2461- # Persist to DB and emit (skip for read-only)
2462- if tasks is not None :
2463- await Chats .update_chat_tasks_by_id (__chat_id__ , all_tasks )
2464-
2465- if __event_emitter__ :
2466- await __event_emitter__ (
2467- {
2468- 'type' : 'chat:message:tasks' ,
2469- 'data' : {
2470- 'tasks' : all_tasks ,
2471- },
2472- }
2473- )
2427+ return json .dumps (
2428+ {'tasks' : all_tasks , 'summary' : _task_summary (all_tasks )},
2429+ ensure_ascii = False ,
2430+ )
2431+ except Exception as e :
2432+ log .exception (f'tasks error: { e } ' )
2433+ return json .dumps ({'error' : str (e )})
24742434
2475- # Build summary counts
2476- pending = sum (1 for t in all_tasks if t ['status' ] == 'pending' )
2477- in_progress = sum (1 for t in all_tasks if t ['status' ] == 'in_progress' )
2478- completed = sum (1 for t in all_tasks if t ['status' ] == 'completed' )
2479- cancelled = sum (1 for t in all_tasks if t ['status' ] == 'cancelled' )
2435+
2436+ async def update_task (
2437+ id : str ,
2438+ status : str = 'completed' ,
2439+ __chat_id__ : str = None ,
2440+ __message_id__ : str = None ,
2441+ __event_emitter__ : callable = None ,
2442+ __request__ : Request = None ,
2443+ __user__ : dict = None ,
2444+ ) -> str :
2445+ """
2446+ Mark a single task as completed, in_progress, pending, or cancelled.
2447+ Call this after finishing each step. You MUST call this for every
2448+ task, including the very last one.
2449+
2450+ :param id: The task ID to update
2451+ :param status: New status: completed, in_progress, pending, or cancelled (default: completed)
2452+ :return: JSON with the updated task list and summary counts
2453+ """
2454+ if __chat_id__ is None :
2455+ return json .dumps ({'error' : 'Chat context not available' })
2456+
2457+ try :
2458+ status = status .strip ().lower ()
2459+ if status not in VALID_TASK_STATUSES :
2460+ return json .dumps ({'error' : f'Invalid status: { status } . Must be one of: { ", " .join (sorted (VALID_TASK_STATUSES ))} ' })
2461+
2462+ all_tasks = await Chats .get_chat_tasks_by_id (__chat_id__ )
2463+
2464+ found = False
2465+ for task in all_tasks :
2466+ if task ['id' ] == id :
2467+ task ['status' ] = status
2468+ found = True
2469+ break
2470+
2471+ if not found :
2472+ return json .dumps ({'error' : f'Task with id "{ id } " not found' })
2473+
2474+ await Chats .update_chat_tasks_by_id (__chat_id__ , all_tasks )
2475+ await _emit_tasks (__event_emitter__ , all_tasks )
24802476
24812477 return json .dumps (
2482- {
2483- 'tasks' : all_tasks ,
2484- 'summary' : {
2485- 'total' : len (all_tasks ),
2486- 'pending' : pending ,
2487- 'in_progress' : in_progress ,
2488- 'completed' : completed ,
2489- 'cancelled' : cancelled ,
2490- },
2491- },
2478+ {'tasks' : all_tasks , 'summary' : _task_summary (all_tasks )},
24922479 ensure_ascii = False ,
24932480 )
24942481 except Exception as e :
2495- log .exception (f'tasks error: { e } ' )
2482+ log .exception (f'update_task_status error: { e } ' )
24962483 return json .dumps ({'error' : str (e )})
24972484
24982485
0 commit comments