- Use pathlib for directory listing and filtering - Use filemap for building file trees from selected directories - Update build_file_map to use pathlib for recursive file listing - Handle filemap building for standalone files and selected directories
491 lines
12 KiB
V
491 lines
12 KiB
V
module heroprompt
|
||
|
||
import rand
|
||
import time
|
||
import os
|
||
import incubaid.herolib.core.pathlib
|
||
import incubaid.herolib.ai.filemap
|
||
|
||
// Selection API
|
||
@[params]
|
||
pub struct AddDirParams {
|
||
pub mut:
|
||
path string @[required]
|
||
}
|
||
|
||
@[params]
|
||
pub struct AddFileParams {
|
||
pub mut:
|
||
path string @[required]
|
||
}
|
||
|
||
// add a directory to the selection (no recursion stored; recursion is done on-demand)
|
||
pub fn (mut wsp Workspace) add_dir(args AddDirParams) !HeropromptChild {
|
||
if args.path.len == 0 {
|
||
return error('the directory path is required')
|
||
}
|
||
|
||
if !os.exists(args.path) || !os.is_dir(args.path) {
|
||
return error('path is not an existing directory: ${args.path}')
|
||
}
|
||
|
||
abs_path := os.real_path(args.path)
|
||
name := os.base(abs_path)
|
||
|
||
for child in wsp.children {
|
||
if child.name == name {
|
||
return error('another directory with the same name already exists: ${name}')
|
||
}
|
||
}
|
||
|
||
mut ch := HeropromptChild{
|
||
path: pathlib.Path{
|
||
path: abs_path
|
||
cat: .dir
|
||
exist: .yes
|
||
}
|
||
name: name
|
||
include_tree: true
|
||
}
|
||
wsp.children << ch
|
||
wsp.save()!
|
||
return ch
|
||
}
|
||
|
||
// add a file to the selection
|
||
pub fn (mut wsp Workspace) add_file(args AddFileParams) !HeropromptChild {
|
||
if args.path.len == 0 {
|
||
return error('The file path is required')
|
||
}
|
||
|
||
if !os.exists(args.path) || !os.is_file(args.path) {
|
||
return error('Path is not an existing file: ${args.path}')
|
||
}
|
||
|
||
abs_path := os.real_path(args.path)
|
||
name := os.base(abs_path)
|
||
|
||
for child in wsp.children {
|
||
if child.path.cat == .file && child.name == name {
|
||
return error('another file with the same name already exists: ${name}')
|
||
}
|
||
|
||
if child.path.cat == .dir && child.name == name {
|
||
return error('${name}: is a directory, cannot add file with same name')
|
||
}
|
||
}
|
||
|
||
content := os.read_file(abs_path) or { '' }
|
||
mut ch := HeropromptChild{
|
||
path: pathlib.Path{
|
||
path: abs_path
|
||
cat: .file
|
||
exist: .yes
|
||
}
|
||
name: name
|
||
content: content
|
||
}
|
||
|
||
wsp.children << ch
|
||
wsp.save()!
|
||
return ch
|
||
}
|
||
|
||
// Removal API
|
||
@[params]
|
||
pub struct RemoveParams {
|
||
pub mut:
|
||
path string
|
||
name string
|
||
}
|
||
|
||
// Remove a directory from the selection (by absolute path or name)
|
||
pub fn (mut wsp Workspace) remove_dir(args RemoveParams) ! {
|
||
if args.path.len == 0 && args.name.len == 0 {
|
||
return error('either path or name is required to remove a directory')
|
||
}
|
||
mut idxs := []int{}
|
||
for i, ch in wsp.children {
|
||
if ch.path.cat != .dir {
|
||
continue
|
||
}
|
||
if args.path.len > 0 && os.real_path(args.path) == ch.path.path {
|
||
idxs << i
|
||
continue
|
||
}
|
||
if args.name.len > 0 && args.name == ch.name {
|
||
idxs << i
|
||
}
|
||
}
|
||
if idxs.len == 0 {
|
||
return error('no matching directory found to remove')
|
||
}
|
||
// remove from end to start to keep indices valid
|
||
idxs.sort(a > b)
|
||
for i in idxs {
|
||
wsp.children.delete(i)
|
||
}
|
||
wsp.save()!
|
||
}
|
||
|
||
// Remove a file from the selection (by absolute path or name)
|
||
pub fn (mut wsp Workspace) remove_file(args RemoveParams) ! {
|
||
if args.path.len == 0 && args.name.len == 0 {
|
||
return error('either path or name is required to remove a file')
|
||
}
|
||
mut idxs := []int{}
|
||
for i, ch in wsp.children {
|
||
if ch.path.cat != .file {
|
||
continue
|
||
}
|
||
if args.path.len > 0 && os.real_path(args.path) == ch.path.path {
|
||
idxs << i
|
||
continue
|
||
}
|
||
if args.name.len > 0 && args.name == ch.name {
|
||
idxs << i
|
||
}
|
||
}
|
||
if idxs.len == 0 {
|
||
return error('no matching file found to remove')
|
||
}
|
||
idxs.sort(a > b)
|
||
for i in idxs {
|
||
wsp.children.delete(i)
|
||
}
|
||
wsp.save()!
|
||
}
|
||
|
||
// Delete this workspace from the store
|
||
pub fn (wsp &Workspace) delete_workspace() ! {
|
||
delete(name: wsp.name)!
|
||
}
|
||
|
||
// Update this workspace (name and/or base_path)
|
||
@[params]
|
||
pub struct UpdateParams {
|
||
pub mut:
|
||
name string
|
||
base_path string
|
||
}
|
||
|
||
pub fn (wsp &Workspace) update_workspace(args UpdateParams) !&Workspace {
|
||
mut updated := Workspace{
|
||
name: if args.name.len > 0 { args.name } else { wsp.name }
|
||
base_path: if args.base_path.len > 0 { args.base_path } else { wsp.base_path }
|
||
children: wsp.children
|
||
created: wsp.created
|
||
updated: time.now()
|
||
is_saved: true
|
||
}
|
||
// if name changed, delete old key first
|
||
if updated.name != wsp.name {
|
||
delete(name: wsp.name)!
|
||
}
|
||
set(updated)!
|
||
return get(name: updated.name)!
|
||
}
|
||
|
||
// @[params]
|
||
// pub struct UpdateParams {
|
||
// pub mut:
|
||
// name string
|
||
// base_path string
|
||
// // Update only the name and the base path for now
|
||
// }
|
||
|
||
// // Delete this workspace from the store
|
||
// pub fn (wsp &Workspace) update_workspace(args_ UpdateParams) ! {
|
||
// delete(name: wsp.name)!
|
||
// }
|
||
|
||
// List workspaces (wrapper over factory list)
|
||
pub fn list_workspaces() ![]&Workspace {
|
||
return list(fromdb: false)!
|
||
}
|
||
|
||
pub fn list_workspaces_fromdb() ![]&Workspace {
|
||
return list(fromdb: true)!
|
||
}
|
||
|
||
// List entries in a directory relative to this workspace base or absolute
|
||
@[params]
|
||
pub struct ListArgs {
|
||
pub mut:
|
||
path string // if empty, will use workspace.base_path
|
||
}
|
||
|
||
pub struct ListItem {
|
||
pub:
|
||
name string
|
||
typ string @[json: 'type']
|
||
}
|
||
|
||
pub fn (wsp &Workspace) list_dir(rel_path string) ![]ListItem {
|
||
// Use pathlib to list directory with default ignore patterns
|
||
full_path := if rel_path.len == 0 {
|
||
wsp.base_path
|
||
} else {
|
||
os.join_path(wsp.base_path, rel_path)
|
||
}
|
||
mut dir := pathlib.get(full_path)
|
||
|
||
// List with default ignore patterns (files starting with . and _)
|
||
mut list_result := dir.list(recursive: false, ignore_default: true)!
|
||
|
||
mut out := []ListItem{}
|
||
for mut path_item in list_result.paths {
|
||
typ := if path_item.is_dir() { 'dir' } else { 'file' }
|
||
out << ListItem{
|
||
name: os.base(path_item.path)
|
||
typ: typ
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
pub fn (wsp &Workspace) list() ![]ListItem {
|
||
return wsp.list_dir('')
|
||
}
|
||
|
||
// Get the currently selected children (copy)
|
||
pub fn (wsp Workspace) selected_children() []HeropromptChild {
|
||
return wsp.children.clone()
|
||
}
|
||
|
||
// build_file_content generates formatted content for all selected files (and all files under selected dirs)
|
||
fn (wsp Workspace) build_file_content() !string {
|
||
mut content := ''
|
||
// files selected directly
|
||
for ch in wsp.children {
|
||
if ch.path.cat == .file {
|
||
if content.len > 0 {
|
||
content += '\n\n'
|
||
}
|
||
content += '${ch.path.path}\n'
|
||
ext := get_file_extension(ch.name)
|
||
if ch.content.len == 0 {
|
||
// read on demand
|
||
ch_content := os.read_file(ch.path.path) or { '' }
|
||
if ch_content.len == 0 {
|
||
content += '(Empty file)\n'
|
||
} else {
|
||
content += '```' + ext + '\n' + ch_content + '\n```'
|
||
}
|
||
} else {
|
||
content += '```' + ext + '\n' + ch.content + '\n```'
|
||
}
|
||
}
|
||
}
|
||
// files under selected directories, using filemap for filtered traversal
|
||
for ch in wsp.children {
|
||
if ch.path.cat == .dir && ch.include_tree {
|
||
mut fm := filemap.filemap(path: ch.path.path)!
|
||
for rel, fc in fm.content {
|
||
if content.len > 0 {
|
||
content += '\n\n'
|
||
}
|
||
abs := os.join_path(ch.path.path, rel)
|
||
content += abs + '\n'
|
||
ext := get_file_extension(os.base(abs))
|
||
if fc.len == 0 {
|
||
content += '(Empty file)\n'
|
||
} else {
|
||
content += '```' + ext + '\n' + fc + '\n```'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return content
|
||
}
|
||
|
||
pub struct HeropromptTmpPrompt {
|
||
pub mut:
|
||
user_instructions string
|
||
file_map string
|
||
file_contents string
|
||
}
|
||
|
||
fn (wsp Workspace) build_user_instructions(text string) string {
|
||
return text
|
||
}
|
||
|
||
// build_file_map creates a complete file map with base path and metadata
|
||
fn (wsp Workspace) build_file_map() !string {
|
||
mut file_map := ''
|
||
// roots are selected directories
|
||
mut roots := []HeropromptChild{}
|
||
mut files_only := []HeropromptChild{}
|
||
for ch in wsp.children {
|
||
if ch.path.cat == .dir && ch.include_tree {
|
||
roots << ch // only include directories explicitly marked to include subtree
|
||
} else if ch.path.cat == .file {
|
||
files_only << ch
|
||
}
|
||
}
|
||
if roots.len > 0 || files_only.len > 0 {
|
||
// derive a parent path for display
|
||
mut parent_path := ''
|
||
if roots.len > 0 {
|
||
base_path := roots[0].path.path
|
||
parent_path = if base_path.contains('/') {
|
||
base_path.split('/')[..base_path.split('/').len - 1].join('/')
|
||
} else {
|
||
base_path
|
||
}
|
||
} else {
|
||
// no roots; show workspace base if set, else the parent of first file
|
||
parent_path = if wsp.base_path.len > 0 {
|
||
wsp.base_path
|
||
} else if files_only.len > 0 {
|
||
os.dir(files_only[0].path.path)
|
||
} else {
|
||
''
|
||
}
|
||
}
|
||
// metadata
|
||
mut total_files := 0
|
||
mut total_content_length := 0
|
||
mut file_extensions := map[string]int{}
|
||
// files under dirs (only when roots present)
|
||
if roots.len > 0 {
|
||
for r in roots {
|
||
mut dir := pathlib.get(r.path.path)
|
||
mut file_list := dir.list(recursive: true, files_only: true)!
|
||
for mut f in file_list.paths {
|
||
total_files++
|
||
ext := get_file_extension(os.base(f.path))
|
||
if ext.len > 0 {
|
||
file_extensions[ext] = file_extensions[ext] + 1
|
||
}
|
||
total_content_length += (os.read_file(f.path) or { '' }).len
|
||
}
|
||
}
|
||
}
|
||
// standalone files
|
||
for fo in files_only {
|
||
total_files++
|
||
ext := get_file_extension(fo.name)
|
||
if ext.len > 0 {
|
||
file_extensions[ext] = file_extensions[ext] + 1
|
||
}
|
||
// if content not loaded, read length on demand
|
||
file_len := if fo.content.len == 0 {
|
||
(os.read_file(fo.path.path) or { '' }).len
|
||
} else {
|
||
fo.content.len
|
||
}
|
||
total_content_length += file_len
|
||
}
|
||
mut extensions_summary := ''
|
||
for ext, count in file_extensions {
|
||
if extensions_summary.len > 0 {
|
||
extensions_summary += ', '
|
||
}
|
||
extensions_summary += '${ext}(${count})'
|
||
}
|
||
file_map = '${parent_path}\n'
|
||
file_map += '# Selected Files: ${total_files} | Total Content: ${total_content_length} chars'
|
||
if extensions_summary.len > 0 {
|
||
file_map += ' | Extensions: ${extensions_summary}'
|
||
}
|
||
file_map += '\n\n'
|
||
// Render selected structure
|
||
if roots.len > 0 {
|
||
mut root_paths := []string{}
|
||
for r in roots {
|
||
root_paths << r.path.path
|
||
}
|
||
file_map += build_file_tree_fs(root_paths, '')
|
||
}
|
||
// If there are only standalone selected files (no selected dirs),
|
||
// build a minimal tree relative to the workspace base.
|
||
if files_only.len > 0 && roots.len == 0 {
|
||
mut paths := []string{}
|
||
for fo in files_only {
|
||
paths << fo.path.path
|
||
}
|
||
file_map += build_selected_tree(paths, wsp.base_path)
|
||
} else if files_only.len > 0 && roots.len > 0 {
|
||
// Keep listing absolute paths for standalone files when directories are also selected.
|
||
for fo in files_only {
|
||
file_map += fo.path.path + ' *\n'
|
||
}
|
||
}
|
||
}
|
||
return file_map
|
||
}
|
||
|
||
pub struct WorkspacePrompt {
|
||
pub mut:
|
||
text string
|
||
}
|
||
|
||
pub fn (wsp Workspace) prompt(args WorkspacePrompt) string {
|
||
user_instructions := wsp.build_user_instructions(args.text)
|
||
file_map := wsp.build_file_map() or { '(Error building file map)' }
|
||
file_contents := wsp.build_file_content() or { '(Error building file contents)' }
|
||
prompt := HeropromptTmpPrompt{
|
||
user_instructions: user_instructions
|
||
file_map: file_map
|
||
file_contents: file_contents
|
||
}
|
||
reprompt := $tmpl('./templates/prompt.template')
|
||
return reprompt
|
||
}
|
||
|
||
// Save the workspace
|
||
fn (mut wsp Workspace) save() !&Workspace {
|
||
wsp.updated = time.now()
|
||
wsp.is_saved = true
|
||
set(wsp)!
|
||
return get(name: wsp.name)!
|
||
}
|
||
|
||
// Generate a random name for the workspace
|
||
pub fn generate_random_workspace_name() string {
|
||
adjectives := [
|
||
'brave',
|
||
'bright',
|
||
'clever',
|
||
'swift',
|
||
'noble',
|
||
'mighty',
|
||
'fearless',
|
||
'bold',
|
||
'wise',
|
||
'epic',
|
||
'valiant',
|
||
'fierce',
|
||
'legendary',
|
||
'heroic',
|
||
'dynamic',
|
||
]
|
||
nouns := [
|
||
'forge',
|
||
'script',
|
||
'ocean',
|
||
'phoenix',
|
||
'atlas',
|
||
'quest',
|
||
'shield',
|
||
'dragon',
|
||
'code',
|
||
'summit',
|
||
'path',
|
||
'realm',
|
||
'spark',
|
||
'anvil',
|
||
'saga',
|
||
]
|
||
|
||
// Seed randomness with time
|
||
rand.seed([u32(time.now().unix()), u32(time.now().nanosecond)])
|
||
|
||
adj := adjectives[rand.intn(adjectives.len) or { 0 }]
|
||
noun := nouns[rand.intn(nouns.len) or { 0 }]
|
||
number := rand.intn(100) or { 0 } // 0–99
|
||
|
||
return '${adj}_${noun}_${number}'
|
||
}
|