projects preps

frdel committed Oct 23, 2025 at 13:08 UTC c4bf352d9c52bae9da4cc9dd9fda06fd3059b51c
9 files changed +236 -4
agent.py
+11 -2
@@ -53,6 +53,7 @@ class AgentContext:
53 created_at: datetime | None = None,
54 type: AgentContextType = AgentContextType.USER,
55 last_message: datetime | None = None,
56 + data: dict | None = None,
57 ):
58 # build context
59 self.id = id or AgentContext.generate_id()
@@ -67,8 +68,8 @@ class AgentContext:
68 self.type = type
69 AgentContext._counter += 1
70 self.no = AgentContext._counter
70 - # set to start of unix epoch
71 self.last_message = last_message or datetime.now(timezone.utc)
72 + self.data = data or {}
73
74 existing = self._contexts.get(self.id, None)
75 if existing:
@@ -112,7 +113,15 @@ class AgentContext:
113 context.task.kill()
114 return context
115
115 - def serialize(self):
116 + def get_data(self, key: str, recursive: bool = True):
117 + # recursive is not used now, prepared for context hierarchy
118 + return self.data.get(key, None)
119 +
120 + def set_data(self, key: str, value: Any, recursive: bool = True):
121 + # recursive is not used now, prepared for context hierarchy
122 + self.data[key] = value
123 +
124 + def output(self):
125 return {
126 "id": self.id,
127 "name": self.name,
python/api/poll.py
+1 -1
@@ -54,7 +54,7 @@ class Poll(ApiHandler):
54 continue
55
56 # Create the base context data that will be returned
57 - context_data = ctx.serialize()
57 + context_data = ctx.output()
58
59 context_task = scheduler.get_task_by_uuid(ctx.id)
60 # Determine if this is a task-dedicated context by checking if a task with this UUID exists
python/api/projects.py new
+26
@@ -0,0 +1,26 @@
1 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
2 +from python.helpers import projects
3 +
4 +
5 +class Projects(ApiHandler):
6 + async def process(self, input: Input, request: Request) -> Output:
7 + action = input.get("action", "")
8 +
9 + try:
10 + if action == "list":
11 + data = self.get_projects_list()
12 + else:
13 + raise Exception("Invalid action")
14 +
15 + return {
16 + "ok": True,
17 + "data": data,
18 + }
19 + except Exception as e:
20 + return {
21 + "ok": False,
22 + "error": str(e),
23 + }
24 +
25 + async def get_projects_list(self):
26 + return await projects.get_projects_list()
python/helpers/files.py
+34
@@ -334,6 +334,40 @@ def delete_dir(relative_path: str):
334 # suppress all errors - we're ensuring no errors propagate
335 pass
336
337 +def move_dir(old_path: str, new_path: str):
338 + # rename/move the directory from old_path to new_path (both relative)
339 + abs_old = get_abs_path(old_path)
340 + abs_new = get_abs_path(new_path)
341 + if not os.path.isdir(abs_old):
342 + return # nothing to rename
343 + try:
344 + os.rename(abs_old, abs_new)
345 + except Exception:
346 + pass # suppress all errors, keep behavior consistent
347 +
348 +# move dir safely, remove with number if needed
349 +def move_dir_safe(src, dst):
350 + base_dst = dst
351 + i = 2
352 + while exists(dst):
353 + dst = f"{base_dst} {i}"
354 + i += 1
355 + move_dir(src, dst)
356 + return dst
357 +
358 +# create dir safely, add number if needed
359 +def create_dir_safe(dst):
360 + base_dst = dst
361 + i = 2
362 + while exists(dst):
363 + dst = f"{base_dst} {i}"
364 + i += 1
365 + create_dir(dst)
366 + return dst
367 +
368 +def create_dir(relative_path: str):
369 + abs_path = get_abs_path(relative_path)
370 + os.makedirs(abs_path, exist_ok=True)
371
372 def list_files(relative_path: str, filter: str = "*"):
373 abs_path = get_abs_path(relative_path)
python/helpers/projects.py new
+111
@@ -0,0 +1,111 @@
1 +from dataclasses import dataclass
2 +import os
3 +from typing import TypedDict
4 +
5 +from torch import ne
6 +from python.helpers import files, dirty_json
7 +from python.helpers.print_style import PrintStyle
8 +
9 +PROJECTS_PARENT_DIR = "tmp/projects"
10 +PROJECTS_ARCHIVE_DIR = "tmp/projects-archived"
11 +PROJECT_META_DIR = ".a0proj"
12 +PROJECT_INSTRUCTIONS_DIR = "instructions"
13 +PROJECT_HEADER_FILE = "project.json"
14 +
15 +
16 +class BasicProjectData(TypedDict):
17 + title: str | None
18 + description: str | None
19 + instructions: str | None
20 + color: str | None
21 +
22 +
23 +def get_projects_parent_folder():
24 + return files.get_abs_path(PROJECTS_PARENT_DIR)
25 +
26 +
27 +def get_projects_archive_folder():
28 + return files.get_abs_path(PROJECTS_ARCHIVE_DIR)
29 +
30 +
31 +def get_project_folder(name: str):
32 + return files.get_abs_path(get_projects_parent_folder(), name)
33 +
34 +
35 +def get_archived_project_folder(name: str):
36 + return files.get_abs_path(get_projects_archive_folder(), name)
37 +
38 +
39 +def archive_project(name: str):
40 + return files.move_dir_safe(
41 + get_project_folder(name), get_archived_project_folder(name)
42 + )
43 +
44 +
45 +def unarchive_project(name: str):
46 + return files.move_dir_safe(
47 + get_archived_project_folder(name), get_project_folder(name)
48 + )
49 +
50 +
51 +def delete_project(path: str):
52 + files.delete_dir(path)
53 +
54 +
55 +async def create_project(name: str, data: BasicProjectData):
56 + new_name = files.create_dir_safe(get_project_folder(name))
57 + save_project_files(new_name, data)
58 +
59 +
60 +async def update_project(path: str, data: BasicProjectData):
61 + current: BasicProjectData = load_basic_project_data(path) # type: ignore
62 + current.update(data)
63 + save_project_files(path, current)
64 +
65 +
66 +def load_basic_project_data(path: str) -> BasicProjectData:
67 + data = dirty_json.parse(
68 + files.read_file(
69 + files.get_abs_path(path, PROJECT_HEADER_FILE)
70 + )
71 + )
72 + return data # type: ignore
73 +
74 +
75 +def save_project_files(path: str, data: BasicProjectData):
76 + # save project header file
77 + header = dirty_json.stringify(data)
78 + files.write_file(
79 + files.get_abs_path(path, PROJECT_HEADER_FILE), header
80 + )
81 +
82 +async def get_active_projects_list():
83 + return await get_projects_list(get_projects_parent_folder())
84 +
85 +async def get_archived_projects_list():
86 + return await get_projects_list(get_projects_archive_folder())
87 +
88 +async def get_projects_list(parent_dir):
89 + projects = []
90 +
91 + # folders in project directory
92 + for name in os.listdir(parent_dir):
93 + try:
94 + path = os.path.join(parent_dir, name)
95 + if os.path.isdir(path):
96 +
97 + project_data = load_basic_project_data(path)
98 +
99 + projects.append({
100 + "name": name,
101 + "path": path,
102 + "title": project_data.get("title", ""),
103 + "description": project_data.get("description", ""),
104 + })
105 + except Exception as e:
106 + PrintStyle.error(f"Error loading project {name}: {str(e)}")
107 +
108 + # sort projects by name
109 +
110 + projects.sort(key=lambda x: x["name"])
111 + return projects
python/helpers/tokens.py
+1 -1
@@ -13,7 +13,7 @@ def count_tokens(text: str, encoding_name="cl100k_base") -> int:
13 encoding = tiktoken.get_encoding(encoding_name)
14
15 # Encode the text and count the tokens
16 - tokens = encoding.encode(text)
16 + tokens = encoding.encode(text, disallowed_special=())
17 token_count = len(tokens)
18
19 return token_count
webui/components/projects/project-selector.html new
+24
@@ -0,0 +1,24 @@
1 +<html>
2 +
3 +<head>
4 + <script type="module">
5 + import { store } from "/components/projects/projectsStore.js";
6 + </script>
7 +</head>
8 +
9 +<body>
10 + <div x-data>
11 + <template x-if="$store.projects">
12 + <div>
13 + <template x-if="$store.selectedProject">
14 + <button type="button" class="btn btn-outline-primary" x-text="$store.selectedProject.name"></button>
15 + </template>
16 + <template x-if="!$store.selectedProject">
17 + <button type="button" class="btn btn-outline-secondary">No project</button>
18 + </template>
19 + </div>
20 + </template>
21 + </div>
22 +</body>
23 +
24 +</html>
\ No newline at end of file
webui/components/projects/projectsStore.js new
+26
@@ -0,0 +1,26 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import * as api from "/js/api.js";
3 +
4 +// define the model object holding data and functions
5 +const model = {
6 + projectList: [],
7 + selectedProject: null,
8 +
9 + async loadProjectsList() {
10 + this.loading = true;
11 + try {
12 + const response = await api.callJsonApi("projects", { action: "list" });
13 + this.projectList = response.data;
14 + } catch (error) {
15 + console.error("Error loading projects list:", error);
16 + } finally {
17 + this.loading = false;
18 + }
19 + },
20 +};
21 +
22 +// convert it to alpine store
23 +const store = createStore("projects", model);
24 +
25 +// export for use in other files
26 +export { store };
webui/index.html
+2
@@ -161,6 +161,8 @@
161 </div>
162 <!-- Notification Toggle positioned next to time-date -->
163 <x-component path="notifications/notification-icons.html"></x-component>
164 + <!-- Project Selector -->
165 + <x-component path="projects/project-selector.html"></x-component>
166 </div>
167 <!-- Message History (actual messages) -->
168 <div id="chat-history">