Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions crates/memory/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,15 @@ impl MemoryStore {
Ok(removed)
}

fn delete_ns(&self, namespace: &str, key: &str) -> Result<bool, MemoryError> {
let path = self.resolve_existing_key_path(namespace, key);
if !path.exists() {
return Ok(false);
}
std::fs::remove_file(&path).map_err(|e| MemoryError::Io { path, source: e })?;
Ok(true)
}

pub fn scoped(self: &Arc<Self>, namespace: &str) -> ScopedMemoryStore {
ScopedMemoryStore {
inner: Arc::clone(self),
Expand Down Expand Up @@ -569,6 +578,11 @@ impl ScopedMemoryStore {
self.inner.purge_expired_ns(&self.namespace)
}

/// Delete a memory key from this scope. Returns true when a file was removed.
pub fn delete(&self, key: &str) -> Result<bool, MemoryError> {
self.inner.delete_ns(&self.namespace, key)
}

/// Read a key plus all entries linked via `related_to` (1-hop graph expansion).
/// Returns the primary entry first, followed by any related entries that exist.
pub fn read_with_related(&self, key: &str) -> Result<Vec<(String, String)>, MemoryError> {
Expand Down Expand Up @@ -1083,4 +1097,17 @@ mod tests {
assert_eq!(removed, 1);
assert!(!path.exists());
}

#[test]
fn delete_removes_existing_key_only_once() {
let dir = TempDir::new().unwrap();
let store = Arc::new(MemoryStore::new(dir.path()));
let scoped = store.scoped("project");
scoped.write("topic:item", "value").unwrap();

assert_eq!(scoped.read("topic:item").unwrap().as_deref(), Some("value"));
assert!(scoped.delete("topic:item").unwrap());
assert_eq!(scoped.read("topic:item").unwrap(), None);
assert!(!scoped.delete("topic:item").unwrap());
}
}
28 changes: 28 additions & 0 deletions crates/sharing/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1524,6 +1524,34 @@ mod tests {
.exists());
}

#[test]
fn builder_exports_selected_skill_package_directory() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
let workflows_dir = dir.path().join("workflows");
let package_dir = skills_dir.join("ui-ux-pro-max");
std::fs::create_dir_all(&package_dir).unwrap();
std::fs::create_dir_all(&workflows_dir).unwrap();
std::fs::write(
package_dir.join("SKILL.md"),
"---\nname: ui-ux-pro-max\ndescription: \"Actions: design, build\"\ntrigger: /ui-ux-pro-max\n---\nUse {{args}}\n",
)
.unwrap();
std::fs::write(package_dir.join("references.md"), "helper docs\n").unwrap();

let bundle = BundleBuilder::new([&skills_dir], [&workflows_dir])
.build(&["ui-ux-pro-max"], &["__none__"], &[], &[])
.unwrap();
let filenames: HashSet<String> = bundle
.skills
.iter()
.map(|asset| asset.filename.clone())
.collect();

assert!(filenames.contains("ui-ux-pro-max/SKILL.md"));
assert!(filenames.contains("ui-ux-pro-max/references.md"));
}

#[test]
fn importer_restores_project_tools() {
let dir = tempfile::tempdir().unwrap();
Expand Down
1 change: 1 addition & 0 deletions crates/web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ async-trait = { workspace = true }
futures = { workspace = true }
tempfile = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
sha2 = "0.10"
ts-rs = { version = "10", features = ["serde-compat", "no-serde-warnings"] }
Expand Down
7 changes: 7 additions & 0 deletions crates/web/frontend/src/composables/useApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,18 @@ export function useApi() {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`)
return r.text()
}),
deleteMemory: (scope, key) =>
fetchJson(`/api/memory/${encodeURIComponent(scope)}/${encodeURIComponent(key)}`, { method: 'DELETE' }),
purgeExpiredMemory: (scope) =>
fetchJson(`/api/memory/${encodeURIComponent(scope)}/_actions/purge-expired`, { method: 'POST' }),

// Status
getStatus: () => fetchJson('/api/status'),
getStats: () => fetchJson('/api/stats'),
getRuntimeSessions: (limit = 12) => fetchJson(`/api/runtime/sessions?limit=${encodeURIComponent(limit)}`),
getRuntimeMessages: (id) => fetchJson(`/api/runtime/sessions/${encodeURIComponent(id)}/messages`),
postRuntimeMessage: (id, data) =>
fetchJson(`/api/runtime/sessions/${encodeURIComponent(id)}/messages`, { method: 'POST', body: JSON.stringify(data) }),
getProviderStatus: () => fetchJson('/api/providers/status'),
getScorecards: (limit = 100) => fetchJson(`/api/scorecards?limit=${encodeURIComponent(limit)}`),
evaluateRegression: (params = {}) => {
Expand Down
99 changes: 99 additions & 0 deletions crates/web/frontend/src/views/DashboardView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const selectedArtifactPath = ref('')
const selectedArtifactPreview = ref(null)
const artifactPreviewStatus = ref('')
const artifactPreviewRef = ref(null)
const runtimeMessages = ref([])
const runtimeMessageText = ref('')
const runtimeMessageStatus = ref('')
const runtimeMessageBusy = ref(false)
const approvalStatus = ref('')
const approvalEditContent = ref('')
const resumeStatus = ref('')
Expand Down Expand Up @@ -528,6 +532,7 @@ async function refreshRuns() {
expandedRunId.value = null
selectedRunId.value = null
selectedRun.value = null
clearRuntimeMessages()
}
}
}
Expand All @@ -537,6 +542,7 @@ async function toggleRun(id) {
expandedRunId.value = null
selectedRunId.value = null
selectedRun.value = null
clearRuntimeMessages()
clearArtifactPreview()
approvalStatus.value = ''
resumeStatus.value = ''
Expand All @@ -549,11 +555,53 @@ async function toggleRun(id) {
async function selectRun(id) {
selectedRunId.value = id
selectedRun.value = await api.getRunDetail(id)
await loadRuntimeMessages(id)
approvalEditContent.value = selectedRun.value?.workflow_state?.pending_approval?.content || ''
resumeStatus.value = ''
clearArtifactPreview()
}

function clearRuntimeMessages() {
runtimeMessages.value = []
runtimeMessageText.value = ''
runtimeMessageStatus.value = ''
runtimeMessageBusy.value = false
}

async function loadRuntimeMessages(id = selectedRunId.value) {
if (!id) {
runtimeMessages.value = []
return
}
try {
const result = await api.getRuntimeMessages(id)
runtimeMessages.value = Array.isArray(result?.messages) ? result.messages : []
} catch {
runtimeMessages.value = []
}
}

async function postRuntimeMessage() {
if (!selectedRunId.value || !runtimeMessageText.value.trim() || runtimeMessageBusy.value) return
runtimeMessageBusy.value = true
runtimeMessageStatus.value = 'Saving note...'
try {
await api.postRuntimeMessage(selectedRunId.value, {
author: 'operator',
kind: 'note',
body: runtimeMessageText.value.trim(),
})
runtimeMessageText.value = ''
runtimeMessageStatus.value = ''
await loadRuntimeMessages(selectedRunId.value)
selectedRun.value = await api.getRunDetail(selectedRunId.value)
} catch (e) {
runtimeMessageStatus.value = `Error: ${e.message}`
} finally {
runtimeMessageBusy.value = false
}
}

function clearArtifactPreview() {
selectedArtifactPath.value = ''
selectedArtifactPreview.value = null
Expand Down Expand Up @@ -1113,6 +1161,57 @@ async function submitTask() {
v-html="renderMarkdown(selectedRunOutput)"
/></div>

<!-- Session notes -->
<div class="rounded-xl border border-base-300/60 bg-base-200/35 p-4">
<div class="flex items-center justify-between gap-3 mb-3">
<div>
<div class="text-[10px] font-mono font-bold uppercase tracking-widest text-base-content/35">Session Notes</div>
<div class="font-mono text-xs text-base-content/45 mt-1">Operator and agent messages attached to this run for handoff, review, and continuation context.</div>
</div>
<span class="badge badge-sm badge-ghost font-mono">{{ runtimeMessages.length }} note(s)</span>
</div>

<div v-if="runtimeMessages.length" class="space-y-2 max-h-48 overflow-auto pr-1 mb-3">
<div
v-for="message in runtimeMessages"
:key="message.id"
class="rounded-lg border border-base-300/50 bg-base-100 p-3"
>
<div class="flex items-center justify-between gap-2 mb-1">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-base-content/70">{{ message.author || 'operator' }}</span>
<span class="badge badge-xs badge-ghost font-mono">{{ message.kind || 'note' }}</span>
</div>
<span class="text-[10px] text-base-content/30 font-mono">{{ fmtLocalTime(message.created_at) }}</span>
</div>
<div class="font-mono text-xs text-base-content/55 whitespace-pre-wrap leading-relaxed">{{ message.body }}</div>
</div>
</div>
<div v-else class="rounded-lg border border-dashed border-base-300 bg-base-100/40 p-3 mb-3 font-mono text-xs text-base-content/35">
No notes yet. Add a compact handoff, decision, or next-action note.
</div>

<div class="flex flex-col lg:flex-row gap-2">
<textarea
v-model="runtimeMessageText"
class="textarea textarea-bordered textarea-sm flex-1 font-mono min-h-[74px]"
placeholder="Add a session note for future agents..."
@keydown.meta.enter.prevent="postRuntimeMessage"
@keydown.ctrl.enter.prevent="postRuntimeMessage"
></textarea>
<div class="flex lg:flex-col gap-2 lg:w-36">
<button
class="btn btn-sm btn-primary font-mono text-xs"
:class="{ 'loading': runtimeMessageBusy }"
:disabled="!runtimeMessageText.trim() || runtimeMessageBusy"
@click="postRuntimeMessage"
>add note</button>
<button class="btn btn-sm btn-ghost font-mono text-xs" @click="loadRuntimeMessages()">refresh</button>
</div>
</div>
<div v-if="runtimeMessageStatus" class="font-mono text-[10px] mt-2" :class="runtimeMessageStatus.startsWith('Error') ? 'text-error' : 'text-base-content/40'">{{ runtimeMessageStatus }}</div>
</div>

<!-- Artifacts -->
<div v-if="selectedRunArtifacts.length" class="rounded-xl border border-base-300/60 bg-base-200/35 p-4">
<div class="flex items-center justify-between gap-3 mb-3">
Expand Down
28 changes: 28 additions & 0 deletions crates/web/frontend/src/views/MemoryView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ async function copyContent() {
}
}

async function deleteSelectedMemory() {
if (!selectedKey.value) return
if (!confirm(`Delete memory key '${selectedKey.value}' from ${activeScope.value}?`)) return
try {
await api.deleteMemory(activeScope.value, selectedKey.value)
await loadKeys(activeScope.value)
showRagToast('Memory key deleted')
} catch (e) {
showRagToast(e.message || 'Delete failed', 'error')
}
}

async function purgeExpiredMemory() {
try {
const result = await api.purgeExpiredMemory(activeScope.value)
await loadKeys(activeScope.value)
showRagToast(`Purged ${result?.purged ?? 0} expired entr${result?.purged === 1 ? 'y' : 'ies'}`)
} catch (e) {
showRagToast(e.message || 'Purge failed', 'error')
}
}

// Render markdown via marked (GFM) + DOMPurify (XSS safe)
function renderMarkdown(raw) {
if (!raw) return ''
Expand Down Expand Up @@ -461,6 +483,9 @@ watch(contentVisible, async (visible) => {
/>
<button v-if="searchQuery" class="text-base-content/30 hover:text-base-content/70 cursor-pointer" @click="searchQuery = ''">&times;</button>
</label>
<button class="btn btn-xs btn-ghost font-mono w-full mt-2 justify-start" @click="purgeExpiredMemory">
purge expired
</button>
</div>

<!-- Key list -->
Expand Down Expand Up @@ -533,6 +558,9 @@ watch(contentVisible, async (visible) => {
<button v-if="content && !loadingContent" class="btn btn-xs btn-ghost font-mono flex-shrink-0" @click="copyContent">
{{ copyStatus || '⎘ Copy' }}
</button>
<button v-if="selectedKey && !loadingContent" class="btn btn-xs btn-ghost text-error font-mono flex-shrink-0" @click="deleteSelectedMemory">
delete
</button>
</template>
<span v-else class="font-mono text-[0.72rem] text-base-content/20 italic">← select a key</span>
<span v-if="loadingContent" class="loading loading-spinner loading-xs ml-auto text-primary/40"></span>
Expand Down
Loading
Loading