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
45 changes: 0 additions & 45 deletions brain-bar/Sources/BrainBar/BrainBarSupport.swift

This file was deleted.

190 changes: 190 additions & 0 deletions brain-bar/Sources/BrainBar/BrainDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,20 @@ final class BrainDatabase: @unchecked Sendable {
)
""")

try execute("""
CREATE TABLE IF NOT EXISTS kg_entity_chunks (
entity_id TEXT NOT NULL,
chunk_id TEXT NOT NULL,
relevance REAL DEFAULT 1.0,
PRIMARY KEY (entity_id, chunk_id)
)
""")

try execute("""
CREATE INDEX IF NOT EXISTS idx_kg_ec_entity
ON kg_entity_chunks(entity_id)
""")

try execute("""
CREATE TABLE IF NOT EXISTS injection_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Expand Down Expand Up @@ -1417,6 +1431,121 @@ final class BrainDatabase: @unchecked Sendable {
return result
}

// MARK: - Knowledge Graph bulk queries

struct KGEntityRow: Equatable {
let id: String
let name: String
let entityType: String
let description: String?
let importance: Double
}

struct KGRelationRow: Equatable {
let id: String
let sourceId: String
let targetId: String
let relationType: String
}

struct KGChunkRow: Equatable {
let chunkID: String
let snippet: String
let importance: Int
let relevance: Double
}

func fetchKGEntities(limit: Int = 500) throws -> [KGEntityRow] {
guard let db else { throw DBError.notOpen }
let sql = """
SELECT id, name, entity_type, description,
COALESCE(CAST(json_extract(metadata, '$.importance') AS REAL), 5.0) AS importance
FROM kg_entities
ORDER BY importance DESC
LIMIT ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
throw DBError.prepare(sqlite3_errcode(db))
}
defer { sqlite3_finalize(stmt) }
sqlite3_bind_int(stmt, 1, Int32(limit))

var rows: [KGEntityRow] = []
while sqlite3_step(stmt) == SQLITE_ROW {
rows.append(KGEntityRow(
id: columnText(stmt, 0) ?? "",
name: columnText(stmt, 1) ?? "",
entityType: columnText(stmt, 2) ?? "",
description: columnText(stmt, 3),
importance: sqlite3_column_double(stmt, 4)
))
}
return rows
Comment on lines +1475 to +1484
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low BrainBar/BrainDatabase.swift:1475

The while sqlite3_step(stmt) == SQLITE_ROW loop exits on any non-SQLITE_ROW result, including error codes like SQLITE_BUSY or SQLITE_CORRUPT. This silently returns partial results when the database encounters an error mid-iteration. Consider checking sqlite3_errcode(db) after the loop and throwing if it indicates an error rather than SQLITE_DONE.

        while sqlite3_step(stmt) == SQLITE_ROW {
            rows.append(KGEntityRow(
                id: columnText(stmt, 0) ?? "",
                name: columnText(stmt, 1) ?? "",
                entityType: columnText(stmt, 2) ?? "",
                description: columnText(stmt, 3),
                importance: sqlite3_column_double(stmt, 4)
            ))
        }
+        let rc = sqlite3_errcode(db)
+        guard rc == SQLITE_DONE || rc == SQLITE_OK else {
+            throw DBError.query(rc)
+        }
         return rows
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file brain-bar/Sources/BrainBar/BrainDatabase.swift around lines 1475-1484:

The `while sqlite3_step(stmt) == SQLITE_ROW` loop exits on any non-`SQLITE_ROW` result, including error codes like `SQLITE_BUSY` or `SQLITE_CORRUPT`. This silently returns partial results when the database encounters an error mid-iteration. Consider checking `sqlite3_errcode(db)` after the loop and throwing if it indicates an error rather than `SQLITE_DONE`.

Evidence trail:
brain-bar/Sources/BrainBar/BrainDatabase.swift lines 1475-1483 at REVIEWED_COMMIT: shows `while sqlite3_step(stmt) == SQLITE_ROW` loop with `return rows` immediately after, no error checking. SQLite documentation (sqlite.org/c3ref/step.html, sqlite.org/rescode.html) confirms sqlite3_step() can return SQLITE_BUSY, SQLITE_CORRUPT, and other error codes in addition to SQLITE_ROW and SQLITE_DONE.

}

func fetchKGRelations() throws -> [KGRelationRow] {
guard let db else { throw DBError.notOpen }
let sql = "SELECT id, source_id, target_id, relation_type FROM kg_relations"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
throw DBError.prepare(sqlite3_errcode(db))
}
defer { sqlite3_finalize(stmt) }

var rows: [KGRelationRow] = []
while sqlite3_step(stmt) == SQLITE_ROW {
rows.append(KGRelationRow(
id: columnText(stmt, 0) ?? "",
sourceId: columnText(stmt, 1) ?? "",
targetId: columnText(stmt, 2) ?? "",
relationType: columnText(stmt, 3) ?? ""
))
}
return rows
}

func linkEntityChunk(entityId: String, chunkId: String, relevance: Double = 1.0) throws {
guard let db else { throw DBError.notOpen }
let sql = "INSERT OR REPLACE INTO kg_entity_chunks (entity_id, chunk_id, relevance) VALUES (?, ?, ?)"
try runWriteStatement(on: db, sql: sql, retries: 3) { stmt in
bindText(entityId, to: stmt, index: 1)
bindText(chunkId, to: stmt, index: 2)
sqlite3_bind_double(stmt, 3, relevance)
}
}

func fetchEntityChunks(entityId: String, limit: Int = 20) throws -> [KGChunkRow] {
guard let db else { throw DBError.notOpen }
let sql = """
SELECT c.id, COALESCE(NULLIF(c.summary, ''), substr(c.content, 1, 200)) AS snippet,
c.importance, ec.relevance
FROM kg_entity_chunks ec
JOIN chunks c ON c.id = ec.chunk_id
WHERE ec.entity_id = ?
ORDER BY ec.relevance DESC
LIMIT ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
throw DBError.prepare(sqlite3_errcode(db))
}
defer { sqlite3_finalize(stmt) }
bindText(entityId, to: stmt, index: 1)
sqlite3_bind_int(stmt, 2, Int32(limit))

var rows: [KGChunkRow] = []
while sqlite3_step(stmt) == SQLITE_ROW {
rows.append(KGChunkRow(
chunkID: columnText(stmt, 0) ?? "",
snippet: columnText(stmt, 1) ?? "",
importance: Int(sqlite3_column_int(stmt, 2)),
relevance: sqlite3_column_double(stmt, 3)
))
}
return rows
}

// MARK: - brain_recall

func recallSession(sessionId: String, limit: Int = 20) throws -> [[String: Any]] {
Expand Down Expand Up @@ -1500,6 +1629,67 @@ final class BrainDatabase: @unchecked Sendable {
return values
}

// MARK: - Injection events

@discardableResult
func recordInjectionEvent(sessionID: String, query: String, chunkIDs: [String], tokenCount: Int, timestamp: String? = nil) -> InjectionEvent {
guard let db else {
return InjectionEvent(id: 0, sessionID: sessionID, timestamp: "", query: query, chunkIDs: chunkIDs, tokenCount: tokenCount)
}
let chunkJSON = (try? JSONSerialization.data(withJSONObject: chunkIDs)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
let ts = timestamp ?? Self.sqliteDateFormatter.string(from: Date())
let sql = "INSERT INTO injection_events (session_id, timestamp, query, chunk_ids, token_count) VALUES (?, ?, ?, ?, ?)"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
return InjectionEvent(id: 0, sessionID: sessionID, timestamp: ts, query: query, chunkIDs: chunkIDs, tokenCount: tokenCount)
}
defer { sqlite3_finalize(stmt) }
bindText(sessionID, to: stmt, index: 1)
bindText(ts, to: stmt, index: 2)
bindText(query, to: stmt, index: 3)
bindText(chunkJSON, to: stmt, index: 4)
sqlite3_bind_int(stmt, 5, Int32(tokenCount))
sqlite3_step(stmt)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low BrainBar/BrainDatabase.swift:1652

In recordInjectionEvent(), the return value of sqlite3_step(stmt) on line 1652 is ignored. When the INSERT fails (e.g., SQLITE_BUSY or disk full), the function still calls sqlite3_last_insert_rowid(db) and returns an InjectionEvent with an invalid id—either 0 or a stale rowid from a previous insert. Callers receive no indication that recording failed, and the id does not correspond to any actual database row.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file brain-bar/Sources/BrainBar/BrainDatabase.swift around line 1652:

In `recordInjectionEvent()`, the return value of `sqlite3_step(stmt)` on line 1652 is ignored. When the INSERT fails (e.g., `SQLITE_BUSY` or disk full), the function still calls `sqlite3_last_insert_rowid(db)` and returns an `InjectionEvent` with an invalid `id`—either 0 or a stale rowid from a previous insert. Callers receive no indication that recording failed, and the `id` does not correspond to any actual database row.

Evidence trail:
brain-bar/Sources/BrainBar/BrainDatabase.swift lines 1640-1655 at REVIEWED_COMMIT:
- Line 1652: `sqlite3_step(stmt)` - return value is discarded
- Line 1653: `let rowID = sqlite3_last_insert_rowid(db)` - called unconditionally
- Line 1654: `return InjectionEvent(id: rowID, ...)` - returns potentially invalid id

SQLite documentation confirms `sqlite3_last_insert_rowid()` behavior: returns 0 or stale rowid when INSERT fails.

let rowID = sqlite3_last_insert_rowid(db)
return InjectionEvent(id: rowID, sessionID: sessionID, timestamp: ts, query: query, chunkIDs: chunkIDs, tokenCount: tokenCount)
}

// MARK: - Injection event listing

func listInjectionEvents(sessionID: String? = nil, limit: Int = 20) throws -> [InjectionEvent] {
guard let db else { throw DBError.notOpen }
var sql = "SELECT id, session_id, timestamp, query, chunk_ids, token_count FROM injection_events"
if sessionID != nil { sql += " WHERE session_id = ?" }
sql += " ORDER BY timestamp DESC LIMIT ?"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
throw DBError.prepare(sqlite3_errcode(db))
}
defer { sqlite3_finalize(stmt) }
var idx: Int32 = 1
if let sessionID {
bindText(sessionID, to: stmt, index: idx)
idx += 1
}
sqlite3_bind_int(stmt, idx, Int32(limit))

var events: [InjectionEvent] = []
while sqlite3_step(stmt) == SQLITE_ROW {
let row: [String: Any] = [
"id": Int64(sqlite3_column_int64(stmt, 0)),
"session_id": columnText(stmt, 1) as Any,
"timestamp": columnText(stmt, 2) as Any,
"query": columnText(stmt, 3) as Any,
"chunk_ids": columnText(stmt, 4) as Any,
"token_count": Int(sqlite3_column_int(stmt, 5))
]
if let event = try? InjectionEvent(row: row) {
events.append(event)
}
}
return events
}

// MARK: - brain_digest: rule-based entity extraction

func digest(content: String) throws -> [String: Any] {
Expand Down
125 changes: 125 additions & 0 deletions brain-bar/Sources/BrainBar/KnowledgeGraph/KGCanvasView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import SwiftUI

struct KGCanvasView: View {
@ObservedObject var viewModel: KGViewModel

@State private var offset: CGSize = .zero
@State private var scale: CGFloat = 1.0
@State private var draggedNodeId: String?
@State private var timerActive = true

var body: some View {
HStack(spacing: 0) {
graphCanvas
if viewModel.selectedEntity != nil {
KGSidebarView(
entity: viewModel.selectedEntity,
chunks: viewModel.selectedEntityChunks,
onClose: { viewModel.selectNode(id: nil) }
)
}
}
.onAppear {
viewModel.loadGraph()
startSimulation()
}
Comment on lines +22 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium KnowledgeGraph/KGCanvasView.swift:22

timerActive is set to false in onDisappear but never reset to true in onAppear. If the view reappears without full recreation (e.g., via navigation), startSimulation() is called but the while timerActive loop exits immediately because timerActive remains false. The simulation will not run on subsequent appearances.

         .onAppear {
             viewModel.loadGraph()
+            timerActive = true
             startSimulation()
         }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file brain-bar/Sources/BrainBar/KnowledgeGraph/KGCanvasView.swift around lines 22-25:

`timerActive` is set to `false` in `onDisappear` but never reset to `true` in `onAppear`. If the view reappears without full recreation (e.g., via navigation), `startSimulation()` is called but the `while timerActive` loop exits immediately because `timerActive` remains `false`. The simulation will not run on subsequent appearances.

Evidence trail:
brain-bar/Sources/BrainBar/KnowledgeGraph/KGCanvasView.swift at REVIEWED_COMMIT:
- Line 10: `@State private var timerActive = true` (initialization)
- Lines 20-23: `onAppear` block calls `loadGraph()` and `startSimulation()` but does NOT reset `timerActive`
- Line 24: `.onDisappear { timerActive = false }`
- Lines 96-101: `startSimulation()` contains `while timerActive` loop that will exit immediately if `timerActive` is false

.onDisappear { timerActive = false }
}

private var graphCanvas: some View {
Canvas { context, size in
var ctx = context
// Apply pan + zoom transform
ctx.translateBy(x: offset.width + size.width / 2, y: offset.height + size.height / 2)
ctx.scaleBy(x: scale, y: scale)
ctx.translateBy(x: -size.width / 2, y: -size.height / 2)

let environment = EnvironmentValues()
let nodeIndex = Dictionary(uniqueKeysWithValues: viewModel.nodes.map { ($0.id, $0) })

// Draw edges first (behind nodes)
for edge in viewModel.edges {
guard let src = nodeIndex[edge.sourceId], let tgt = nodeIndex[edge.targetId] else { continue }
let highlighted = viewModel.selectedNodeId == edge.sourceId || viewModel.selectedNodeId == edge.targetId
KGEdgeRenderer.draw(
edge: edge, sourcePos: src.position, targetPos: tgt.position,
isHighlighted: highlighted, in: &ctx, environment: environment
)
}

// Draw nodes
for node in viewModel.nodes {
KGNodeRenderer.draw(
node: node,
isSelected: node.id == viewModel.selectedNodeId,
in: &ctx, environment: environment
)
}
}
.background(Color.black.opacity(0.85))
.gesture(tapGesture)
.gesture(dragGesture)
.gesture(magnifyGesture)
.overlay(alignment: .topLeading) { statsOverlay }
}

// MARK: - Gestures

private var tapGesture: some Gesture {
SpatialTapGesture()
.onEnded { value in
let point = canvasPoint(from: value.location, in: .zero)
if let node = viewModel.nodeAt(point: point) {
viewModel.selectNode(id: node.id)
} else {
viewModel.selectNode(id: nil)
}
}
}

private var dragGesture: some Gesture {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High KnowledgeGraph/KGCanvasView.swift:80

The dragGesture overwrites offset with only the current gesture's translation, discarding any previously accumulated offset. After releasing a drag and starting a new one, the canvas jumps back toward the origin because the previous pan position is lost. Consider tracking a base offset and adding the translation to it during drag.

-    private var dragGesture: some Gesture {
-        DragGesture()
-            .onChanged { value in
-                offset = CGSize(
-                    width: value.translation.width,
-                    height: value.translation.height
-                )
-            }
-    }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file brain-bar/Sources/BrainBar/KnowledgeGraph/KGCanvasView.swift around line 80:

The `dragGesture` overwrites `offset` with only the current gesture's translation, discarding any previously accumulated offset. After releasing a drag and starting a new one, the canvas jumps back toward the origin because the previous pan position is lost. Consider tracking a base offset and adding the translation to it during drag.

Evidence trail:
brain-bar/Sources/BrainBar/KnowledgeGraph/KGCanvasView.swift lines 6 (offset declaration), 80-87 (dragGesture implementation) at REVIEWED_COMMIT. git_grep confirmed no baseOffset/accumulatedOffset/savedOffset exists in the file.

DragGesture()
.onChanged { value in
offset = CGSize(
width: value.translation.width,
height: value.translation.height
)
}
}

private var magnifyGesture: some Gesture {
MagnifyGesture()
.onChanged { value in
scale = max(0.2, min(3.0, value.magnification))
}
}
Comment on lines +90 to +95
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High KnowledgeGraph/KGCanvasView.swift:90

The magnifyGesture resets scale to near-1.0 whenever a new pinch begins because value.magnification always starts at 1.0. If the user is zoomed to 2x and starts another pinch, the zoom jumps back toward 1.0 instead of continuing from 2x. Consider storing a baseScale at gesture start and multiplying it by value.magnification in onChanged.

-    private var magnifyGesture: some Gesture {
+    @State private var lastScale: CGFloat = 1.0
+
+    private var magnifyGesture: some Gesture {
         MagnifyGesture()
+            .onChanged { value in
+                scale = max(0.2, min(3.0, lastScale * value.magnification))
+            }
+            .onEnded { _ in
+                lastScale = scale
+            }
+    }
+}
-            .onChanged { value in
-                scale = max(0.2, min(3.0, value.magnification))
-            }
-    }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file brain-bar/Sources/BrainBar/KnowledgeGraph/KGCanvasView.swift around lines 90-95:

The `magnifyGesture` resets `scale` to near-1.0 whenever a new pinch begins because `value.magnification` always starts at 1.0. If the user is zoomed to 2x and starts another pinch, the zoom jumps back toward 1.0 instead of continuing from 2x. Consider storing a `baseScale` at gesture start and multiplying it by `value.magnification` in `onChanged`.

Evidence trail:
brain-bar/Sources/BrainBar/KnowledgeGraph/KGCanvasView.swift lines 90-95 (magnifyGesture implementation), line 7 (@State private var scale), git_grep for baseScale/GestureState/onEnded confirms no baseScale mechanism exists.


// MARK: - Coordinate transform

private func canvasPoint(from screenPoint: CGPoint, in size: CGSize) -> CGPoint {
CGPoint(
x: (screenPoint.x - offset.width) / scale,
y: (screenPoint.y - offset.height) / scale
)
}
Comment on lines +99 to +104
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High KnowledgeGraph/KGCanvasView.swift:99

canvasPoint(from:in:) ignores the center-based transform applied in the Canvas block. The canvas translates to center, scales, then translates back, but the inverse function only undoes offset and scale—it never accounts for the centering translations. This causes tapGesture to compute wrong coordinates, so node hit-testing selects the wrong node (or misses entirely) when the view is zoomed or panned. The function needs to subtract size.width/2 before scaling and add it back after, matching the forward transform.

-    private func canvasPoint(from screenPoint: CGPoint, in size: CGSize) -> CGPoint {
-        CGPoint(
-            x: (screenPoint.x - offset.width) / scale,
-            y: (screenPoint.y - offset.height) / scale
-        )
-    }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file brain-bar/Sources/BrainBar/KnowledgeGraph/KGCanvasView.swift around lines 99-104:

`canvasPoint(from:in:)` ignores the center-based transform applied in the Canvas block. The canvas translates to center, scales, then translates back, but the inverse function only undoes `offset` and `scale`—it never accounts for the centering translations. This causes `tapGesture` to compute wrong coordinates, so node hit-testing selects the wrong node (or misses entirely) when the view is zoomed or panned. The function needs to subtract `size.width/2` before scaling and add it back after, matching the forward transform.

Evidence trail:
KGCanvasView.swift lines 28-31 (forward transform with centering): `ctx.translateBy(x: offset.width + size.width / 2, ...)`, `ctx.scaleBy(x: scale, y: scale)`, `ctx.translateBy(x: -size.width / 2, ...)`

KGCanvasView.swift lines 99-103 (inverse function): `canvasPoint(from:in:)` only computes `(screenPoint.x - offset.width) / scale` - missing the center-based translations

KGCanvasView.swift line 72: `tapGesture` calls `canvasPoint(from: value.location, in: .zero)` passing `.zero` for size


// MARK: - Simulation timer

private func startSimulation() {
Task { @MainActor in
while timerActive {
try? await Task.sleep(for: .milliseconds(33)) // ~30fps
viewModel.tick()
}
}
}

// MARK: - Stats overlay

private var statsOverlay: some View {
Text("\(viewModel.nodes.count) nodes · \(viewModel.edges.count) edges")
.font(.caption2)
.foregroundColor(.white.opacity(0.6))
.padding(6)
}
}
9 changes: 9 additions & 0 deletions brain-bar/Sources/BrainBar/KnowledgeGraph/KGEdge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

struct KGEdge: Identifiable, Equatable {
let sourceId: String
let targetId: String
let relationType: String

var id: String { "\(sourceId)-\(relationType)-\(targetId)" }
Comment on lines +3 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low KnowledgeGraph/KGEdge.swift:3

The computed id joins sourceId, relationType, and targetId with hyphens, producing collisions when any field contains a hyphen. For example, sourceId: "a-b", relationType: "c", targetId: "d" and sourceId: "a", relationType: "b-c", targetId: "d" both generate id = "a-b-c-d". In ForEach or other Identifiable contexts, this causes one edge to be dropped or incorrectly merged. Consider using a delimiter that cannot appear in the data, or encoding/escaping the fields.

-    var id: String { "\(sourceId)-\(relationType)-\(targetId)" }
+    var id: String { "\(sourceId)\u{001F}\(relationType)\u{001F}\(targetId)" }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file brain-bar/Sources/BrainBar/KnowledgeGraph/KGEdge.swift around lines 3-8:

The computed `id` joins `sourceId`, `relationType`, and `targetId` with hyphens, producing collisions when any field contains a hyphen. For example, `sourceId: "a-b", relationType: "c", targetId: "d"` and `sourceId: "a", relationType: "b-c", targetId: "d"` both generate `id = "a-b-c-d"`. In `ForEach` or other `Identifiable` contexts, this causes one edge to be dropped or incorrectly merged. Consider using a delimiter that cannot appear in the data, or encoding/escaping the fields.

Evidence trail:
brain-bar/Sources/BrainBar/KnowledgeGraph/KGEdge.swift lines 3-8 at REVIEWED_COMMIT: `struct KGEdge: Identifiable, Equatable` with `var id: String { "\(sourceId)-\(relationType)-\(targetId)" }`. The hyphen delimiter creates collision potential when any of the three fields contain hyphens.

}
Loading
Loading