-- ===================================================================== -- lib-core.notify v0.1.0 — Player-Facing Message Routing -- Tag-based messages + channel registry + tag-filter routing. -- Spec: meta/docs/superpowers/specs/2026-06-14-notification-system-design.md -- ===================================================================== -- Module-table early so module-local closures can reference M.* local M = {} -- ---------- module state (all local) ---------- local channels = {} -- channel_id → channel_def local subscribers = {} -- channel_id → array of {callback, sub_id} local next_msg_id = 1 local next_sub_id = 1 local engine_log_mirror = true -- Valid display modes (capability-by-declaration) local VALID_DISPLAY_MODES = { log = true, toast = true, detail = true, } -- Default max_history per display_mode local DEFAULT_MAX_HISTORY = { log = 200, toast = 20, detail = 1, } -- ----------------------------------------------------------------------- -- Internal helpers -- ----------------------------------------------------------------------- local function tags_match(channel_filter, msg_tags) -- Catch-all: empty filter matches everything if #channel_filter == 0 then return true end -- OR-list: ≥1 message-tag in channel_filter for _, mtag in ipairs(msg_tags) do for _, ftag in ipairs(channel_filter) do if mtag == ftag then return true end end end return false end local function append_to_history(channel, msg) table.insert(channel.history, msg) while #channel.history > channel.max_history do table.remove(channel.history, 1) -- drop oldest end end -- ----------------------------------------------------------------------- -- Channel management -- ----------------------------------------------------------------------- function M.create_channel(def) if type(def) ~= "table" then error("notify.create_channel: def must be a table") end if type(def.id) ~= "string" or def.id == "" then error("notify.create_channel: def.id must be a non-empty string") end local id = def.id if channels[id] then error("notify.create_channel '" .. id .. "': duplicate channel id") end if type(def.filter) ~= "table" then error("notify.create_channel '" .. id .. "': def.filter must be a table (array)") end if not VALID_DISPLAY_MODES[def.display_mode] then error("notify.create_channel '" .. id .. "': unknown display_mode '" .. tostring(def.display_mode) .. "'; valid: log, toast, detail") end local max_history = def.max_history if max_history == nil then max_history = DEFAULT_MAX_HISTORY[def.display_mode] end channels[id] = { id = id, filter = def.filter, display_mode = def.display_mode, max_history = max_history, title = def.title, history = {}, } subscribers[id] = {} end function M.destroy_channel(channel_id) channels[channel_id] = nil subscribers[channel_id] = nil end function M.list_channels() local result = {} for _, ch in pairs(channels) do -- shallow copy, omit internal history result[#result + 1] = { id = ch.id, filter = ch.filter, display_mode = ch.display_mode, max_history = ch.max_history, title = ch.title, } end return result end -- ----------------------------------------------------------------------- -- Post -- ----------------------------------------------------------------------- function M.post(msg) if type(msg) ~= "table" then error("notify.post: msg must be a table") end if type(msg.tags) ~= "table" then error("notify.post: msg.tags must be an array of strings") end if #msg.tags == 0 then error("notify.post: msg.tags must be a non-empty array") end for i, tag in ipairs(msg.tags) do if type(tag) ~= "string" then error("notify.post: msg.tags[" .. i .. "] must be a string, got " .. type(tag)) end end if type(msg.text) ~= "string" then error("notify.post: msg.text must be a string") end -- Shallow copy so caller's table is never mutated and history -- entries can't alias across calls. -- Note: tags and data are shared references; caller must not mutate -- them after post(). local stored = { tags = msg.tags, text = msg.text, severity = msg.severity or "info", ttl = msg.ttl, source = msg.source, data = msg.data, } stored.id = next_msg_id next_msg_id = next_msg_id + 1 stored.timestamp = (engine and engine.time and engine.time.now and engine.time.now()) or 0 -- Route to matching channels for _, ch in pairs(channels) do if tags_match(ch.filter, msg.tags) then append_to_history(ch, stored) for _, sub in ipairs(subscribers[ch.id]) do sub.callback(stored) end end end -- Engine-log mirror if engine_log_mirror and engine and engine.print then engine.print(stored.text) end end -- ----------------------------------------------------------------------- -- Sugar wrappers -- ----------------------------------------------------------------------- function M.info(tags, text) M.post{ tags = tags, text = text, severity = "info" } end function M.warn(tags, text) M.post{ tags = tags, text = text, severity = "warn" } end function M.error(tags, text) M.post{ tags = tags, text = text, severity = "error" } end -- ----------------------------------------------------------------------- -- History -- ----------------------------------------------------------------------- function M.get_history(channel_id, n) local ch = channels[channel_id] if not ch then return {} end local history = ch.history local total = #history if n == nil or n >= total then -- Return shallow copy of all local copy = {} for i = 1, total do copy[i] = history[i] end return copy end -- Return last n entries (oldest-first within slice) local copy = {} local start = total - n + 1 for i = start, total do copy[#copy + 1] = history[i] end return copy end -- ----------------------------------------------------------------------- -- Subscribe / Unsubscribe -- ----------------------------------------------------------------------- function M.subscribe(channel_id, callback) if not channels[channel_id] then error("notify.subscribe: unknown channel '" .. tostring(channel_id) .. "'") end if type(callback) ~= "function" then error("notify.subscribe: callback for channel '" .. tostring(channel_id) .. "' must be a function") end local sub_id = next_sub_id next_sub_id = next_sub_id + 1 local entry = { callback = callback, sub_id = sub_id } table.insert(subscribers[channel_id], entry) return { channel_id = channel_id, sub_id = sub_id } end function M.unsubscribe(handle) if type(handle) ~= "table" or handle.channel_id == nil then error("notify.unsubscribe: handle must be a table with channel_id field") end local list = subscribers[handle.channel_id] if not list then return end -- channel destroyed, silent for i, sub in ipairs(list) do if sub.sub_id == handle.sub_id then table.remove(list, i) return end end -- Not found: silent (idempotent) end -- ----------------------------------------------------------------------- -- Engine-log mirror toggle -- ----------------------------------------------------------------------- function M.set_engine_log_mirror(enable) engine_log_mirror = enable == true end -- ----------------------------------------------------------------------- -- Test backdoors -- ----------------------------------------------------------------------- function M._test_clear_all() channels = {} subscribers = {} next_msg_id = 1 next_sub_id = 1 engine_log_mirror = true end function M._test_get_subscribers(channel_id) return subscribers[channel_id] or {} end function M._test_get_log_mirror() return engine_log_mirror end return M